commit 601931f03fabdf77e356659e2b55fd63a1f2dbe3 Author: Pavel Kirilin Date: Thu May 28 16:36:05 2026 +0200 Initial fork commit. Signed-off-by: Pavel Kirilin diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c9ebe8c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +.DS_Store +dist/ +release/ +backup/ +bin/ +db/ +sui +web/html +main +tmp +.sync* +*.tar.gz +frontend/node_modules +frontend/.vite + +# local env files +.env.local +.env.*.local + +# Log files +*.log* +.cache + +# Editor directories and files +.idea +.vscode diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ee92348 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: alireza0 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f3d5c41 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question-template.md b/.github/ISSUE_TEMPLATE/question-template.md new file mode 100644 index 0000000..790bdf8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question-template.md @@ -0,0 +1,10 @@ +--- +name: Question template +about: Ask if it is not clear that it is a bug +title: '' +labels: question +assignees: '' + +--- + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5445036 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..81f9749 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,159 @@ +name: Docker Image CI +on: + push: + tags: + - "*" + workflow_dispatch: + +jobs: + frontend-build: + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + with: + submodules: recursive + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 25 + - name: Install dependencies and build frontend + run: | + cd frontend + npm install + npm run build + - name: Upload frontend build artifact + uses: actions/upload-artifact@v7 + with: + name: frontend-dist + path: frontend/dist/ + + build: + needs: frontend-build + strategy: + fail-fast: false + matrix: + include: + - { platform: linux/amd64 } + - { platform: linux/386 } + - { platform: linux/arm64/v8 } + - { platform: linux/arm/v7 } + - { platform: linux/arm/v6 } + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + - name: Download frontend build artifact + uses: actions/download-artifact@v8 + with: + name: frontend-dist + path: frontend_dist + - name: Prepare + run: | + platform="${{ matrix.platform }}" + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Docker meta + id: meta + uses: docker/metadata-action@v6 + with: + images: | + alireza7/s-ui + ghcr.io/alireza0/s-ui + tags: | + type=ref,event=branch + type=ref,event=tag + type=pep440,pattern={{version}} + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Cache Docker layers + uses: actions/cache@v5 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ matrix.platform }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx-${{ matrix.platform }}- + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.frontend-artifact + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + tags: | + alireza7/s-ui + ghcr.io/alireza0/s-ui + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache,mode=max + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + echo "${digest#sha256:}" > "${{ runner.temp }}/digests/${digest#sha256:}" + - name: Upload digest + uses: actions/upload-artifact@v7 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + needs: build + runs-on: ubuntu-24.04 + steps: + - name: Download digests + uses: actions/download-artifact@v8 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v6 + with: + images: | + alireza7/s-ui + ghcr.io/alireza0/s-ui + tags: | + type=ref,event=branch + type=ref,event=tag + type=pep440,pattern={{version}} + - name: Create manifest list and push + env: + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} + working-directory: ${{ runner.temp }}/digests + run: | + set -e + for img in alireza7/s-ui ghcr.io/alireza0/s-ui; do + TAGS_ARGS=$(echo "$DOCKER_METADATA_OUTPUT_JSON" | jq -cr --arg img "$img" '.tags | map(select(startswith($img))) | map("-t " + .) | join(" ")') + DIGEST_REFS=$(for f in *; do echo -n "${img}@sha256:$(cat "$f") "; done) + docker buildx imagetools create $TAGS_ARGS $DIGEST_REFS + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..353b871 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,202 @@ +name: Release S-UI + +on: + workflow_dispatch: + release: + types: [published] + push: + branches: + - main + tags: + - "*" + paths: + - '.github/workflows/release.yml' + - 'frontend/**' + - '**.sh' + - '**.go' + - 'go.mod' + - 'go.sum' + - 's-ui.service' + +env: + NODE_VERSION: "25" + CRONET_GO_VERSION: "2faf34666c2cc8234f10f2ab6d4c4d6104d34ae2" + CRONET_GO_REPO: https://github.com/sagernet/cronet-go.git + BOOTLIN_BASE_URL: https://toolchains.bootlin.com/downloads/releases/toolchains + +jobs: + build-frontend: + runs-on: ubuntu-latest + steps: + - name: Checkout repository (frontend only) + uses: actions/checkout@v6.0.2 + with: + submodules: recursive + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + run: | + cd frontend + npm install + npm run build + cd .. + + - name: Upload frontend dist + uses: actions/upload-artifact@v7 + with: + name: frontend-dist + path: frontend/dist/ + + build-linux: + name: build-${{ matrix.platform }} + needs: build-frontend + strategy: + fail-fast: false + matrix: + include: + - { platform: amd64, arch: amd64, bootlin: x86-64, naive: true } + - { platform: arm64, arch: arm64, bootlin: aarch64, naive: true } + - { platform: armv7, arch: arm, goarm: "7", bootlin: armv7-eabihf, naive: true } + - { platform: armv6, arch: arm, goarm: "6", bootlin: armv6-eabihf, naive: true } + - { platform: armv5, arch: arm, goarm: "5", bootlin: armv5-eabi, naive: false } + - { platform: "386", arch: "386", bootlin: x86-i686, naive: true } + - { platform: s390x, arch: s390x, bootlin: s390x-z13, naive: false } + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + - name: Download frontend dist + uses: actions/download-artifact@v8 + with: + name: frontend-dist + path: web/html + + - name: Setup Go + uses: actions/setup-go@v6 + with: + cache: false + go-version-file: go.mod + + # Naive platforms: use cronet toolchain only (no Bootlin). + - name: Clone cronet-go (cronet toolchain for naive) + if: matrix.naive + run: | + set -e + git init ~/cronet-go + git -C ~/cronet-go remote add origin ${{ env.CRONET_GO_REPO }} + git -C ~/cronet-go fetch --depth=1 origin "${{ env.CRONET_GO_VERSION }}" + git -C ~/cronet-go checkout FETCH_HEAD + git -C ~/cronet-go submodule update --init --recursive --depth=1 + + - name: Regenerate Debian keyring (cronet sysroot) + if: matrix.naive + run: | + set -e + rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg + cd ~/cronet-go + GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh + + - name: Cache Chromium toolchain + if: matrix.naive + id: cache-chromium-toolchain + uses: actions/cache@v5 + with: + path: | + ~/cronet-go/naiveproxy/src/third_party/llvm-build/ + ~/cronet-go/naiveproxy/src/gn/out/ + ~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/ + ~/cronet-go/naiveproxy/src/out/sysroot-build/ + key: chromium-toolchain-${{ matrix.platform }}-musl-${{ env.CRONET_GO_VERSION }} + + - name: Build cronet lib and set toolchain env (CC, CXX, CGO_LDFLAGS, PATH) + if: matrix.naive + run: | + set -e + cd ~/cronet-go + go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain + go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env | while IFS= read -r line; do + line="${line#export }" + [[ -z "$line" ]] && continue + echo "$line" >> $GITHUB_ENV + done + + - name: Set Go build env (all platforms) + run: | + echo "CGO_ENABLED=1" >> $GITHUB_ENV + echo "GOOS=linux" >> $GITHUB_ENV + echo "GOARCH=${{ matrix.arch }}" >> $GITHUB_ENV + if [ -n "${{ matrix.goarm }}" ]; then echo "GOARM=${{ matrix.goarm }}" >> $GITHUB_ENV; fi + + # Non-naive platforms only: Bootlin musl (armv5, s390x). + - name: Set up Bootlin musl (armv5, s390x) + if: ${{ matrix.naive != true }} + run: | + set -e + BOOTLIN_ARCH="${{ matrix.bootlin }}" + echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})" + TARBALL_BASE="${{ env.BOOTLIN_BASE_URL }}/$BOOTLIN_ARCH/tarballs/" + TARBALL_URL=$(curl -fsSL "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1) + [ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; } + echo "Downloading: $TARBALL_URL" + cd /tmp + curl -fL -sS -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL" + tar -xf "$(basename "$TARBALL_URL")" + TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1) + TOOLCHAIN_DIR="$(realpath "$TOOLCHAIN_DIR")" + BIN_DIR="$TOOLCHAIN_DIR/bin" + echo "PATH=$BIN_DIR:$PATH" >> $GITHUB_ENV + CC=$(find "$BIN_DIR" -maxdepth 1 \( -name '*-gcc.br_real' -o -name '*-gcc' \) -type f -executable 2>/dev/null | grep -v g++ | head -n1) + [ -z "$CC" ] && { echo "No gcc found in $BIN_DIR" >&2; exit 1; } + echo "CC=$(realpath "$CC")" >> $GITHUB_ENV + SYSROOT="" + F=$(find "$TOOLCHAIN_DIR" -name "libc-header-start.h" 2>/dev/null | head -1) + if [ -n "$F" ]; then SYSROOT=$(dirname "$(dirname "$(dirname "$(dirname "$F")")")"); fi + if [ -n "$SYSROOT" ] && [ -d "$SYSROOT" ]; then + echo "CGO_CFLAGS=--sysroot=$SYSROOT" >> $GITHUB_ENV + echo "CGO_LDFLAGS=--sysroot=$SYSROOT -static" >> $GITHUB_ENV + fi + + - name: Build s-ui + run: | + set -e + BUILD_TAGS="with_quic,with_grpc,with_utls,with_acme,with_gvisor,badlinkname,tfogo_checklinkname0,with_tailscale" + [ "${{ matrix.naive }}" = "true" ] && BUILD_TAGS="${BUILD_TAGS},with_naive_outbound,with_musl" + go build -ldflags="-w -s -checklinkname=0 -linkmode external -extldflags '-static'" -tags "$BUILD_TAGS" -o sui main.go + file sui + ldd sui 2>/dev/null || echo "Static binary confirmed" + + mkdir s-ui + cp sui s-ui/ + cp s-ui.service s-ui/ + cp s-ui.sh s-ui/ + + - name: Package + run: tar -zcvf s-ui-linux-${{ matrix.platform }}.tar.gz s-ui + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: s-ui-linux-${{ matrix.platform }} + path: ./s-ui-linux-${{ matrix.platform }}.tar.gz + retention-days: 30 + + - name: Upload to Release + uses: svenstaro/upload-release-action@v2 + if: | + (github.event_name == 'release' && github.event.action == 'published') || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref_name }} + file: s-ui-linux-${{ matrix.platform }}.tar.gz + asset_name: s-ui-linux-${{ matrix.platform }}.tar.gz + prerelease: true + overwrite: true diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..565afa7 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,140 @@ +name: Build S-UI for Windows + +on: + workflow_dispatch: + release: + types: [published] + push: + branches: + - main + tags: + - "*" + paths: + - '.github/workflows/windows.yml' + - 'frontend/**' + - '**.go' + - 'go.mod' + - 'go.sum' + - 'windows/**' + +env: + NODE_VERSION: "25" + TAGS: "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0,with_tailscale" + LIBCRONET_BASE_URL: "https://github.com/SagerNet/cronet-go/releases/latest/download" + +jobs: + build-frontend: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + with: + submodules: recursive + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: 'https://registry.npmjs.org' + + - name: Build frontend + run: | + cd frontend + npm install + npm run build + cd .. + + - name: Upload frontend artifact + uses: actions/upload-artifact@v7 + with: + name: frontend-dist + path: frontend/dist + retention-days: 1 + + build-windows: + needs: build-frontend + name: build-windows-${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + include: + - { arch: amd64, runner: windows-latest, cgo: "1" } + - { arch: arm64, runner: ubuntu-latest, cgo: "0" } + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + - name: Download frontend artifact + uses: actions/download-artifact@v8 + with: + name: frontend-dist + path: web/html + + - name: Setup Go + uses: actions/setup-go@v6 + with: + cache: false + go-version-file: go.mod + + - name: Install zip for Windows + if: matrix.arch == 'amd64' + shell: powershell + run: | + # Install Chocolatey if not available + if (!(Get-Command choco -ErrorAction SilentlyContinue)) { + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + } + # Install zip + choco install zip -y + + - name: Build s-ui + shell: bash + run: | + export CGO_ENABLED=${{ matrix.cgo }} + export GOOS=windows + export GOARCH=${{ matrix.arch }} + + echo "Building for Windows ${{ matrix.arch }}" + go version + go env GOOS GOARCH + + go build -ldflags="-w -s -checklinkname=0" -tags "${{ env.TAGS }}" -o sui.exe main.go + file sui.exe + + mkdir s-ui-windows + cp sui.exe s-ui-windows/ + cp -r windows/* s-ui-windows/ + + - name: Download libcronet-go + shell: bash + run: | + curl -qsL -o s-ui-windows/libcronet.dll ${{ env.LIBCRONET_BASE_URL }}/libcronet-windows-${{ matrix.arch }}.dll + + - name: Package + shell: bash + run: | + zip -r "s-ui-windows-${{ matrix.arch }}.zip" s-ui-windows + + - name: Upload files to Artifacts + uses: actions/upload-artifact@v7 + with: + name: s-ui-windows-${{ matrix.arch }} + path: ./s-ui-windows-${{ matrix.arch }}.zip + retention-days: 30 + + - name: Upload to Release + uses: svenstaro/upload-release-action@v2 + if: | + (github.event_name == 'release' && github.event.action == 'published') || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ github.ref }} + file: s-ui-windows-${{ matrix.arch }}.zip + asset_name: s-ui-windows-${{ matrix.arch }}.zip + prerelease: true + overwrite: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93ad3fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +.DS_Store +dist/ +release/ +backup/ +bin/ +db/ +sui +web/html +main +tmp +.sync* +*.tar.gz +frontend/ + +# local env files +.env.local +.env.*.local + +# Log files +*.log* +.cache + +# Windows build artifacts +*.exe +*.zip +s-ui-windows/ +sui-*.exe +sui-*.zip +windows/sui-*.exe + +# Editor directories and files +.idea +.vscode diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..89c4063 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "frontend"] + path = frontend + url = https://github.com/alireza0/s-ui-frontend + branch = main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..45dd322 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,276 @@ +# Contributing to S-UI + +Thank you for your interest in contributing to S-UI. This document explains how to set up a development environment, follow project conventions, and submit changes. Your contributions help make the **multi-inbound-per-user** approach and the rest of the project better for everyone. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Development Environment Setup](#development-environment-setup) +- [Coding Conventions and Style Guide](#coding-conventions-and-style-guide) +- [Testing](#testing) +- [Features That Need Help](#features-that-need-help) +- [Pull Request Process](#pull-request-process) +- [Adding This Guide in Your Repository](#adding-this-guide-in-your-repository) +- [Reporting Bugs and Requesting Features](#reporting-bugs-and-requesting-features) + +--- + +## Code of Conduct + +Please be respectful and constructive when interacting with maintainers and other contributors. This project is for personal learning and communication; use it responsibly and legally. + +--- + +## Development Environment Setup + +### Prerequisites + +- **Go**: 1.25 or later (see `go.mod` for the exact version). +- **Git**: For cloning and submodules. +- **C compiler**: Required for CGO (e.g. `gcc`, `musl-dev` on Alpine). +- **Node.js** (optional): Only if you plan to work on or rebuild the frontend. The repo can be run with pre-built frontend assets. + +### Clone and Submodules + +```bash +git clone https://github.com/alireza0/s-ui +cd s-ui +git submodule update --init --recursive +``` + +The **frontend** lives in a submodule. If you only work on the backend, you can use the existing `web/html` contents or build the frontend once (see below). + +### Backend-Only Development (quickest) + +1. Build and run with the provided script (builds backend and runs with debug + local DB): + + ```bash + ./runSUI.sh + ``` + + This runs `./build.sh` then `SUI_DB_FOLDER="db" SUI_DEBUG=true ./sui`. + +2. Or build manually: + + ```bash + ./build.sh + SUI_DB_FOLDER=db SUI_DEBUG=true ./sui + ``` + + Default panel: **http://localhost:2095/app/** (user: `admin`, password: `admin` — change in production). + +### Full Stack (Backend + Frontend) + +1. **Frontend** (separate repo in submodule): + + ```bash + cd frontend + npm install + npm run build + cd .. + ``` + +2. **Replace web assets and build backend**: + + ```bash + mkdir -p web/html + rm -rf web/html/* + cp -R frontend/dist/* web/html/ + go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_tailscale" -o sui main.go + ``` + +3. Run: + + ```bash + SUI_DB_FOLDER=db SUI_DEBUG=true ./sui + ``` + +### Build Tags + +The backend is built with these tags for full functionality: + +- `with_quic`, `with_grpc`, `with_utls`, `with_acme`, `with_gvisor`, `with_tailscale` + +Use the same tags when building locally if you need feature parity with releases. + +### Environment Variables (development) + +| Variable | Description | Example | +|----------------|--------------------------------|-----------| +| `SUI_DB_FOLDER`| Directory for SQLite DB files | `db` | +| `SUI_DEBUG` | Enable debug mode | `true` | +| `SUI_LOG_LEVEL`| Log level | `debug` | +| `SUI_BIN_FOLDER` | Directory for binaries | `bin` | + +### Docker (optional) + +```bash +git clone https://github.com/alireza0/s-ui +cd s-ui +git submodule update --init --recursive +docker build -t s-ui . +# or: docker compose up -d +``` + +--- + +## Coding Conventions and Style Guide + +### General + +- Write clear, maintainable code. Prefer small, focused functions and packages. +- Comment non-obvious logic and public APIs. +- Handle errors explicitly; avoid ignoring `err` unless intentional. + +### Go Style + +- Follow **standard Go style** and **[Effective Go](https://go.dev/doc/effective_go)**. +- Run **gofmt** (or **goimports**) before committing: + + ```bash + gofmt -w . + # or: goimports -w . + ``` + +- Use **camelCase** for unexported names and **PascalCase** for exported names. +- Keep package names short and lowercase (e.g. `api`, `service`, `util`). +- Group imports: standard library, then third-party, then project imports (as in existing files). + +### Project Structure Conventions + +- **`api/`**: HTTP handlers and API routing (e.g. `apiHandler.go`, `apiV2Handler.go`). +- **`service/`**: Business logic and panel/core operations. +- **`database/model/`**: GORM models and DB entities. +- **`util/`**: Shared utilities (e.g. link/sub conversion, JSON). +- **`core/`**: sing-box integration and core runtime. +- **`sub/`**: Subscription (link/json) handling. + +When adding new features, place code in the appropriate layer (handler → service → model/util) and avoid circular dependencies. + +### Naming and Patterns + +- Handlers: suffix `Handler` (e.g. `APIHandler`, `APIv2Handler`). +- Services: suffix `Service` or use package name (e.g. `ApiService`, `LinkService`). +- Models: clear struct names with JSON/gorm tags (see `database/model/`). + +--- + +## Testing + +### Current State + +- The project does not yet have a formal test suite (no `*_test.go` files in the repo). +- CI currently focuses on **builds** (e.g. `release.yml`) rather than automated tests. + +### What You Can Do Now + +1. **Build verification**: Before submitting a PR, ensure the project builds: + + ```bash + go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_tailscale" -o sui main.go + ``` + +2. **Manual testing**: Run with `./runSUI.sh`, test the changed area (panel, API, subscription, etc.). + +3. **Future tests**: Contributions that add **unit tests** (e.g. for `util/`, `service/`, or API handlers) or **integration tests** are very welcome. Prefer the standard library `testing` package and table-driven tests where appropriate. + +### Running the Linter (optional) + +```bash +go vet ./... +# Optional: staticcheck, golangci-lint, etc. +``` + +--- + +## Features That Need Help + +Community help is especially valuable in these areas. Check the [Issues](https://github.com/alireza0/s-ui/issues) for current tasks and ideas. + +### High-Value Areas + +- **Multi-inbound per user**: Core differentiator of S-UI; improvements to UX, docs, and robustness are welcome. +- **API (v1 and v2)**: Completeness, consistency, and documentation (see [API Documentation](https://github.com/alireza0/s-ui/wiki/API-Documentation)). +- **Subscription service**: Link conversion, JSON subscription, and info endpoints (`sub/`, `util/`). +- **Testing**: Adding unit and integration tests for critical paths. +- **Documentation**: User docs, API examples, and contribution docs (like this file). +- **Platform support**: macOS is experimental; Windows and Linux improvements are welcome (see `windows/` and `.github/workflows/`). + +### How to Find Tasks + +- **Good first issue**: Look for issues labeled `good first issue` or `help wanted`. +- **Feature requests**: [Feature request template](.github/ISSUE_TEMPLATE/feature_request.md). +- **Bugs**: [Bug report template](.github/ISSUE_TEMPLATE/bug_report.md). + +If you want to work on a larger feature, open an issue first to discuss approach and avoid duplicate work. + +--- + +## Pull Request Process + +1. **Fork and branch** + + - Fork the repository on GitHub. + - Create a branch from `main`: e.g. `git checkout -b fix/issue-123` or `feature/sub-improvements`. + +2. **Make your changes** + + - Follow the [Coding Conventions](#coding-conventions-and-style-guide). + - Run `gofmt` and ensure the project builds (see [Testing](#testing)). + - Keep commits focused and messages clear (e.g. "Fix link conversion for VMess", "Add tests for outJson"). + +3. **Push and open a PR** + + - Push your branch and open a Pull Request against `main`. + - Use the PR description to explain: + - What problem or feature the PR addresses. + - What you changed and how to verify it. + - Reference any related issue (e.g. "Fixes #123"). + +4. **Review and CI** + + - Maintainers will review your code. CI (e.g. build workflows) must pass. + - Address feedback by pushing new commits to the same branch. + +5. **Merge** + + - Once approved and CI is green, a maintainer will merge your PR. Thank you for contributing! + +### PR Guidelines + +- Prefer **small, reviewable PRs**. Split large features into logical steps. +- Avoid unrelated changes (e.g. formatting-only or refactors in a feature PR). +- Ensure your branch is up to date with `main` before submitting (rebase or merge as the project prefers). + +--- + +## Adding This Guide in Your Repository + +If you maintain a fork or your own repository and want the contribution guide to be visible and linked properly: + +1. **Keep `CONTRIBUTING.md` in the repository root** + GitHub automatically discovers a file named `CONTRIBUTING.md` (or `CONTRIBUTING`) in the root. When someone opens a new issue or pull request, GitHub can show a link to it. The community profile also uses it for the “Contributing” section. + +2. **Link from README** + Add a short line in your main `README.md` so new contributors see it when they land on the repo, for example: + ```markdown + **Want to contribute?** See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding conventions, and the pull request process. + ``` + +3. **Optional: GitHub “Contributing” link** + In the repository **Settings → General → Features**, ensure “Issues” (and optionally “Discussions”) are enabled. The link to `CONTRIBUTING.md` appears when users create a new issue or PR; no extra config is needed as long as the file is in the root. + +4. **When forking** + If you fork S-UI, `CONTRIBUTING.md` is already in the repo. Update the clone URLs and repo names in this file if you want your fork’s contribution instructions to point to your own repository. + +--- + +## Reporting Bugs and Requesting Features + +- **Bugs**: Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md). Include version, OS, steps to reproduce, and expected vs actual behavior. +- **Features**: Use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md). Describe the use case and, if possible, a proposed approach. +- **Questions**: Use the [question template](.github/ISSUE_TEMPLATE/question-template.md) or discussions if enabled. + +--- + +Thank you for helping S-UI grow. Your contributions make it possible for more users to adopt S-UI in production and benefit from its multi-inbound-per-user design. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb97d21 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +FROM --platform=$BUILDPLATFORM node:alpine AS front-builder +WORKDIR /app +COPY frontend/ ./ +RUN npm install && npm run build + +FROM golang:1.26-alpine AS backend-builder +WORKDIR /app +ARG TARGETARCH +ARG TARGETVARIANT +ENV CGO_ENABLED=1 +ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE" +ENV GOARCH=$TARGETARCH + +RUN apk update && apk add --no-cache \ + gcc \ + musl-dev \ + libc-dev \ + make \ + git \ + wget \ + unzip \ + bash \ + curl + +ENV CC=gcc + +RUN CRONET_ARCH="$TARGETARCH" && \ + CRONET_URL="https://github.com/SagerNet/cronet-go/releases/latest/download/libcronet-linux-${CRONET_ARCH}.so"; \ + echo "Downloading $CRONET_URL" && \ + wget -q -O ./libcronet.so "$CRONET_URL" && \ + chmod 755 ./libcronet.so + +COPY . . +COPY --from=front-builder /app/dist/ /app/web/html/ + +RUN if [ "$TARGETARCH" = "arm" ]; then export GOARM=7; [ "$TARGETVARIANT" = "v6" ] && export GOARM=6; fi; \ + go build -ldflags="-w -s" \ + -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_naive_outbound,with_purego,with_tailscale" \ + -o sui main.go + +FROM alpine +LABEL org.opencontainers.image.authors="alireza7@gmail.com" +ENV TZ=Asia/Tehran +WORKDIR /app +RUN set -ex && apk add --no-cache --upgrade bash tzdata ca-certificates nftables +COPY --from=backend-builder /app/sui /app/libcronet.so /app/ +COPY entrypoint.sh /app/ +ENTRYPOINT [ "./entrypoint.sh" ] \ No newline at end of file diff --git a/Dockerfile.frontend-artifact b/Dockerfile.frontend-artifact new file mode 100644 index 0000000..434d49b --- /dev/null +++ b/Dockerfile.frontend-artifact @@ -0,0 +1,43 @@ +FROM golang:1.26-alpine AS backend-builder +WORKDIR /app +ARG TARGETARCH +ARG TARGETVARIANT +ENV CGO_ENABLED=1 +ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE" +ENV GOARCH=$TARGETARCH + +RUN apk update && apk add --no-cache \ + gcc \ + musl-dev \ + libc-dev \ + make \ + git \ + wget \ + unzip \ + bash \ + curl + +ENV CC=gcc + +RUN CRONET_ARCH="$TARGETARCH" && \ + CRONET_URL="https://github.com/SagerNet/cronet-go/releases/latest/download/libcronet-linux-${CRONET_ARCH}.so"; \ + echo "Downloading $CRONET_URL" && \ + wget -q -O ./libcronet.so "$CRONET_URL" && \ + chmod 755 ./libcronet.so + +COPY . . +COPY frontend_dist/ /app/web/html/ + +RUN if [ "$TARGETARCH" = "arm" ]; then export GOARM=7; [ "$TARGETVARIANT" = "v6" ] && export GOARM=6; fi; \ + go build -ldflags="-w -s" \ + -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_naive_outbound,with_purego,with_tailscale" \ + -o sui main.go + +FROM alpine +LABEL org.opencontainers.image.authors="alireza7@gmail.com" +ENV TZ=Asia/Tehran +WORKDIR /app +RUN set -ex && apk add --no-cache --upgrade bash tzdata ca-certificates nftables +COPY --from=backend-builder /app/sui /app/libcronet.so /app/ +COPY entrypoint.sh /app/ +ENTRYPOINT [ "./entrypoint.sh" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..51d09e0 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# S-UI +**An Advanced Web Panel • Built on SagerNet/Sing-Box** + +![](https://img.shields.io/github/v/release/alireza0/s-ui.svg) +![S-UI Docker pull](https://img.shields.io/docker/pulls/alireza7/s-ui.svg) +[![Go Report Card](https://goreportcard.com/badge/github.com/alireza0/s-ui)](https://goreportcard.com/report/github.com/alireza0/s-ui) +[![Downloads](https://img.shields.io/github/downloads/alireza0/s-ui/total.svg)](https://img.shields.io/github/downloads/alireza0/s-ui/total.svg) +[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html) + +> **Disclaimer:** This project is only for personal learning and communication, please do not use it for illegal purposes, please do not use it in a production environment + +**If you think this project is helpful to you, you may wish to give a**:star2: + +**Want to contribute?** See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding conventions, testing, and the pull request process. + +[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/alireza7) + + + Crypto donation button by NOWPayments + + +## Quick Overview +| Features | Enable? | +| -------------------------------------- | :----------------: | +| Multi-Protocol | :heavy_check_mark: | +| Multi-Language | :heavy_check_mark: | +| Multi-Client/Inbound | :heavy_check_mark: | +| Advanced Traffic Routing Interface | :heavy_check_mark: | +| Client & Traffic & System Status | :heavy_check_mark: | +| Subscription Link (link/json/clash + info)| :heavy_check_mark: | +| Dark/Light Theme | :heavy_check_mark: | +| API Interface | :heavy_check_mark: | + +## Supported Platforms +| Platform | Architecture | Status | +|----------|--------------|---------| +| Linux | amd64, arm64, armv7, armv6, armv5, 386, s390x | ✅ Supported | +| Windows | amd64, 386, arm64 | ✅ Supported | +| macOS | amd64, arm64 | 🚧 Experimental | + +## Screenshots + +!["Main"](https://github.com/alireza0/s-ui-frontend/raw/main/media/main.png) + +[Other UI Screenshots](https://github.com/alireza0/s-ui-frontend/blob/main/screenshots.md) + +## API Documentation + +[API-Documentation Wiki](https://github.com/alireza0/s-ui/wiki/API-Documentation) + +## Default Installation Information +- Panel Port: 2095 +- Panel Path: /app/ +- Subscription Port: 2096 +- Subscription Path: /sub/ +- User/Password: admin + +## Install & Upgrade to Latest Version + +### Linux/macOS +```sh +bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/master/install.sh) +``` + +### Windows +1. Download the latest Windows release from [GitHub Releases](https://github.com/alireza0/s-ui/releases/latest) +2. Extract the ZIP file +3. Run `install-windows.bat` as Administrator +4. Follow the installation wizard + +## Install legacy Version + +**Step 1:** To install your desired legacy version, add the version to the end of the installation command. e.g., ver `1.0.0`: + +```sh +VERSION=1.0.0 && bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/$VERSION/install.sh) $VERSION +``` + +## Manual installation + +### Linux/macOS +1. Get the latest version of S-UI based on your OS/Architecture from GitHub: [https://github.com/alireza0/s-ui/releases/latest](https://github.com/alireza0/s-ui/releases/latest) +2. **OPTIONAL** Get the latest version of `s-ui.sh` [https://raw.githubusercontent.com/alireza0/s-ui/master/s-ui.sh](https://raw.githubusercontent.com/alireza0/s-ui/master/s-ui.sh) +3. **OPTIONAL** Copy `s-ui.sh` to /usr/bin/ and run `chmod +x /usr/bin/s-ui`. +4. Extract s-ui tar.gz file to a directory of your choice and navigate to the directory where you extracted the tar.gz file. +5. Copy *.service files to /etc/systemd/system/ and run `systemctl daemon-reload`. +6. Enable autostart and start S-UI service using `systemctl enable s-ui --now` +7. Start sing-box service using `systemctl enable sing-box --now` + +### Windows +1. Get the latest Windows version from GitHub: [https://github.com/alireza0/s-ui/releases/latest](https://github.com/alireza0/s-ui/releases/latest) +2. Download the appropriate Windows package (e.g., `s-ui-windows-amd64.zip`) +3. Extract the ZIP file to a directory of your choice +4. Run `install-windows.bat` as Administrator +5. Follow the installation wizard +6. Access the panel at http://localhost:2095/app + +## Uninstall S-UI + +```sh +sudo -i + +systemctl disable s-ui --now + +rm -f /etc/systemd/system/sing-box.service +systemctl daemon-reload + +rm -fr /usr/local/s-ui +rm /usr/bin/s-ui +``` + +## Install using Docker + +
+ Click for details + +### Usage + +**Step 1:** Install Docker + +```shell +curl -fsSL https://get.docker.com | sh +``` + +**Step 2:** Install S-UI + +> Docker compose method + +```shell +mkdir s-ui && cd s-ui +wget -q https://raw.githubusercontent.com/alireza0/s-ui/master/docker-compose.yml +docker compose up -d +``` + +> Use docker + +```shell +mkdir s-ui && cd s-ui +docker run -itd \ + -p 2095:2095 -p 2096:2096 -p 443:443 -p 80:80 \ + -v $PWD/db/:/app/db/ \ + -v $PWD/cert/:/root/cert/ \ + --name s-ui --restart=unless-stopped \ + alireza7/s-ui:latest +``` + +> Build your own image + +```shell +git clone https://github.com/alireza0/s-ui +git submodule update --init --recursive +docker build -t s-ui . +``` + +
+ +## Manual run ( contribution ) + +
+ Click for details + +### Build and run whole project +```shell +./runSUI.sh +``` + +### Clone the repository +```shell +# clone repository +git clone https://github.com/alireza0/s-ui +# clone submodules +git submodule update --init --recursive +``` + + +### - Frontend + +Visit [s-ui-frontend](https://github.com/alireza0/s-ui-frontend) for frontend code + +### - Backend +> Please build frontend once before! + +To build backend: +```shell +# remove old frontend compiled files +rm -fr web/html/* +# apply new frontend compiled files +cp -R frontend/dist/ web/html/ +# build +go build -o sui main.go +``` + +To run backend (from root folder of repository): +```shell +./sui +``` + +
+ +## Languages + +- English +- Farsi +- Vietnamese +- Chinese (Simplified) +- Chinese (Traditional) +- Russian + +## Features + +- Supported protocols: + - General: Mixed, SOCKS, HTTP, HTTPS, Direct, Redirect, TProxy + - V2Ray based: VLESS, VMess, Trojan, Shadowsocks + - Other protocols: ShadowTLS, Hysteria, Hysteria2, Naive, TUIC +- Supports XTLS protocols +- An advanced interface for routing traffic, incorporating PROXY Protocol, External, and Transparent Proxy, SSL Certificate, and Port +- An advanced interface for inbound and outbound configuration +- Clients’ traffic cap and expiration date +- Displays online clients, inbounds and outbounds with traffic statistics, and system status monitoring +- Subscription service with ability to add external links and subscription +- HTTPS for secure access to the web panel and subscription service (self-provided domain + SSL certificate) +- Dark/Light theme + +## Environment Variables + +
+ Click for details + +### Usage + +| Variable | Type | Default | +| -------------- | :--------------------------------------------: | :------------ | +| SUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | +| SUI_DEBUG | `boolean` | `false` | +| SUI_BIN_FOLDER | `string` | `"bin"` | +| SUI_DB_FOLDER | `string` | `"db"` | +| SINGBOX_API | `string` | - | + +
+ +## SSL Certificate + +
+ Click for details + +### Certbot + +```bash +snap install core; snap refresh core +snap install --classic certbot +ln -s /snap/bin/certbot /usr/bin/certbot + +certbot certonly --standalone --register-unsafely-without-email --non-interactive --agree-tos -d +``` + +
+ +## Stargazers over Time +[![Stargazers over time](https://starchart.cc/alireza0/s-ui.svg)](https://starchart.cc/alireza0/s-ui) diff --git a/api/apiHandler.go b/api/apiHandler.go new file mode 100644 index 0000000..5c73cd1 --- /dev/null +++ b/api/apiHandler.go @@ -0,0 +1,107 @@ +package api + +import ( + "strings" + + "github.com/alireza0/s-ui/util/common" + + "github.com/gin-gonic/gin" +) + +type APIHandler struct { + ApiService + apiv2 *APIv2Handler +} + +func NewAPIHandler(g *gin.RouterGroup, a2 *APIv2Handler) { + a := &APIHandler{ + apiv2: a2, + } + a.initRouter(g) +} + +func (a *APIHandler) initRouter(g *gin.RouterGroup) { + g.Use(func(c *gin.Context) { + path := c.Request.URL.Path + if !strings.HasSuffix(path, "login") && !strings.HasSuffix(path, "logout") { + checkLogin(c) + } + }) + g.POST("/:postAction", a.postHandler) + g.GET("/:getAction", a.getHandler) +} + +func (a *APIHandler) postHandler(c *gin.Context) { + loginUser := GetLoginUser(c) + action := c.Param("postAction") + + switch action { + case "login": + a.ApiService.Login(c) + case "changePass": + a.ApiService.ChangePass(c) + case "save": + a.ApiService.Save(c, loginUser) + case "restartApp": + a.ApiService.RestartApp(c) + case "restartSb": + a.ApiService.RestartSb(c) + case "linkConvert": + a.ApiService.LinkConvert(c) + case "subConvert": + a.ApiService.SubConvert(c) + case "importdb": + a.ApiService.ImportDb(c) + case "addToken": + a.ApiService.AddToken(c) + a.apiv2.ReloadTokens() + case "deleteToken": + a.ApiService.DeleteToken(c) + a.apiv2.ReloadTokens() + default: + jsonMsg(c, "failed", common.NewError("unknown action: ", action)) + } +} + +func (a *APIHandler) getHandler(c *gin.Context) { + action := c.Param("getAction") + + switch action { + case "logout": + a.ApiService.Logout(c) + case "load": + a.ApiService.LoadData(c) + case "inbounds", "outbounds", "endpoints", "services", "tls", "clients", "config": + err := a.ApiService.LoadPartialData(c, []string{action}) + if err != nil { + jsonMsg(c, action, err) + } + return + case "users": + a.ApiService.GetUsers(c) + case "settings": + a.ApiService.GetSettings(c) + case "stats": + a.ApiService.GetStats(c) + case "status": + a.ApiService.GetStatus(c) + case "onlines": + a.ApiService.GetOnlines(c) + case "logs": + a.ApiService.GetLogs(c) + case "changes": + a.ApiService.CheckChanges(c) + case "keypairs": + a.ApiService.GetKeypairs(c) + case "getdb": + a.ApiService.GetDb(c) + case "tokens": + a.ApiService.GetTokens(c) + case "singbox-config": + a.ApiService.GetSingboxConfig(c) + case "checkOutbound": + a.ApiService.GetCheckOutbound(c) + default: + jsonMsg(c, "failed", common.NewError("unknown action: ", action)) + } +} diff --git a/api/apiService.go b/api/apiService.go new file mode 100644 index 0000000..9ab0212 --- /dev/null +++ b/api/apiService.go @@ -0,0 +1,405 @@ +package api + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/service" + "github.com/alireza0/s-ui/util" + + "github.com/gin-gonic/gin" +) + +type ApiService struct { + service.SettingService + service.UserService + service.ConfigService + service.ClientService + service.TlsService + service.InboundService + service.OutboundService + service.EndpointService + service.ServicesService + service.PanelService + service.StatsService + service.ServerService +} + +func (a *ApiService) LoadData(c *gin.Context) { + data, err := a.getData(c) + if err != nil { + jsonMsg(c, "", err) + return + } + jsonObj(c, data, nil) +} + +func (a *ApiService) getData(c *gin.Context) (interface{}, error) { + data := make(map[string]interface{}, 0) + lu := c.Query("lu") + isUpdated, err := a.ConfigService.CheckChanges(lu) + if err != nil { + return "", err + } + onlines, err := a.StatsService.GetOnlines() + + sysInfo := a.ServerService.GetSingboxInfo() + if sysInfo["running"] == false { + logs := a.ServerService.GetLogs("1", "debug") + if len(logs) > 0 { + data["lastLog"] = logs[0] + } + } + + if err != nil { + return "", err + } + if isUpdated { + config, err := a.SettingService.GetConfig() + if err != nil { + return "", err + } + clients, err := a.ClientService.GetAll() + if err != nil { + return "", err + } + tlsConfigs, err := a.TlsService.GetAll() + if err != nil { + return "", err + } + inbounds, err := a.InboundService.GetAll() + if err != nil { + return "", err + } + outbounds, err := a.OutboundService.GetAll() + if err != nil { + return "", err + } + endpoints, err := a.EndpointService.GetAll() + if err != nil { + return "", err + } + services, err := a.ServicesService.GetAll() + if err != nil { + return "", err + } + subURI, err := a.SettingService.GetFinalSubURI(getHostname(c)) + if err != nil { + return "", err + } + trafficAge, err := a.SettingService.GetTrafficAge() + if err != nil { + return "", err + } + data["config"] = json.RawMessage(config) + data["clients"] = clients + data["tls"] = tlsConfigs + data["inbounds"] = inbounds + data["outbounds"] = outbounds + data["endpoints"] = endpoints + data["services"] = services + data["subURI"] = subURI + data["enableTraffic"] = trafficAge > 0 + data["onlines"] = onlines + } else { + data["onlines"] = onlines + } + + return data, nil +} + +func (a *ApiService) LoadPartialData(c *gin.Context, objs []string) error { + data := make(map[string]interface{}, 0) + id := c.Query("id") + + for _, obj := range objs { + switch obj { + case "inbounds": + inbounds, err := a.InboundService.Get(id) + if err != nil { + return err + } + data[obj] = inbounds + case "outbounds": + outbounds, err := a.OutboundService.GetAll() + if err != nil { + return err + } + data[obj] = outbounds + case "endpoints": + endpoints, err := a.EndpointService.GetAll() + if err != nil { + return err + } + data[obj] = endpoints + case "services": + services, err := a.ServicesService.GetAll() + if err != nil { + return err + } + data[obj] = services + case "tls": + tlsConfigs, err := a.TlsService.GetAll() + if err != nil { + return err + } + data[obj] = tlsConfigs + case "clients": + clients, err := a.ClientService.Get(id) + if err != nil { + return err + } + data[obj] = clients + case "config": + config, err := a.SettingService.GetConfig() + if err != nil { + return err + } + data[obj] = json.RawMessage(config) + case "settings": + settings, err := a.SettingService.GetAllSetting() + if err != nil { + return err + } + data[obj] = settings + } + } + + jsonObj(c, data, nil) + return nil +} + +func (a *ApiService) GetUsers(c *gin.Context) { + users, err := a.UserService.GetUsers() + if err != nil { + jsonMsg(c, "", err) + return + } + jsonObj(c, *users, nil) +} + +func (a *ApiService) GetSettings(c *gin.Context) { + data, err := a.SettingService.GetAllSetting() + if err != nil { + jsonMsg(c, "", err) + return + } + jsonObj(c, data, err) +} + +func (a *ApiService) GetStats(c *gin.Context) { + resource := c.Query("resource") + tag := c.Query("tag") + limit, err := strconv.Atoi(c.Query("limit")) + if err != nil { + limit = 100 + } + data, err := a.StatsService.GetStats(resource, tag, limit) + if err != nil { + jsonMsg(c, "", err) + return + } + jsonObj(c, data, err) +} + +func (a *ApiService) GetStatus(c *gin.Context) { + request := c.Query("r") + result := a.ServerService.GetStatus(request) + jsonObj(c, result, nil) +} + +func (a *ApiService) GetOnlines(c *gin.Context) { + onlines, err := a.StatsService.GetOnlines() + jsonObj(c, onlines, err) +} + +func (a *ApiService) GetLogs(c *gin.Context) { + count := c.Query("c") + level := c.Query("l") + logs := a.ServerService.GetLogs(count, level) + jsonObj(c, logs, nil) +} + +func (a *ApiService) CheckChanges(c *gin.Context) { + actor := c.Query("a") + chngKey := c.Query("k") + count := c.Query("c") + changes := a.ConfigService.GetChanges(actor, chngKey, count) + jsonObj(c, changes, nil) +} + +func (a *ApiService) GetKeypairs(c *gin.Context) { + kType := c.Query("k") + options := c.Query("o") + keypair := a.ServerService.GenKeypair(kType, options) + jsonObj(c, keypair, nil) +} + +func (a *ApiService) GetDb(c *gin.Context) { + exclude := c.Query("exclude") + db, err := database.GetDb(exclude) + if err != nil { + jsonMsg(c, "", err) + return + } + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Disposition", "attachment; filename=s-ui_"+time.Now().Format("20060102-150405")+".db") + c.Writer.Write(db) +} + +func (a *ApiService) postActions(c *gin.Context) (string, json.RawMessage, error) { + var data map[string]json.RawMessage + err := c.ShouldBind(&data) + if err != nil { + return "", nil, err + } + return string(data["action"]), data["data"], nil +} + +func (a *ApiService) Login(c *gin.Context) { + remoteIP := getRemoteIp(c) + loginUser, err := a.UserService.Login(c.Request.FormValue("user"), c.Request.FormValue("pass"), remoteIP) + if err != nil { + jsonMsg(c, "", err) + return + } + + sessionMaxAge, err := a.SettingService.GetSessionMaxAge() + if err != nil { + logger.Infof("Unable to get session's max age from DB") + } + + err = SetLoginUser(c, loginUser, sessionMaxAge) + if err == nil { + logger.Info("user ", loginUser, " login success") + } else { + logger.Warning("login failed: ", err) + } + + jsonMsg(c, "", nil) +} + +func (a *ApiService) ChangePass(c *gin.Context) { + id := c.Request.FormValue("id") + oldPass := c.Request.FormValue("oldPass") + newUsername := c.Request.FormValue("newUsername") + newPass := c.Request.FormValue("newPass") + err := a.UserService.ChangePass(id, oldPass, newUsername, newPass) + if err == nil { + logger.Info("change user credentials success") + jsonMsg(c, "save", nil) + } else { + logger.Warning("change user credentials failed:", err) + jsonMsg(c, "", err) + } +} + +func (a *ApiService) Save(c *gin.Context, loginUser string) { + hostname := getHostname(c) + obj := c.Request.FormValue("object") + act := c.Request.FormValue("action") + data := c.Request.FormValue("data") + initUsers := c.Request.FormValue("initUsers") + objs, err := a.ConfigService.Save(obj, act, json.RawMessage(data), initUsers, loginUser, hostname) + if err != nil { + jsonMsg(c, "save", err) + return + } + err = a.LoadPartialData(c, objs) + if err != nil { + jsonMsg(c, obj, err) + } +} + +func (a *ApiService) RestartApp(c *gin.Context) { + err := a.PanelService.RestartPanel(3) + jsonMsg(c, "restartApp", err) +} + +func (a *ApiService) RestartSb(c *gin.Context) { + err := a.ConfigService.RestartCore() + jsonMsg(c, "restartSb", err) +} + +func (a *ApiService) LinkConvert(c *gin.Context) { + link := c.Request.FormValue("link") + result, _, err := util.GetOutbound(link, 0) + jsonObj(c, result, err) +} + +func (a *ApiService) SubConvert(c *gin.Context) { + link := c.Request.FormValue("link") + result, err := util.GetExternalSub(link) + jsonObj(c, result, err) +} + +func (a *ApiService) ImportDb(c *gin.Context) { + file, _, err := c.Request.FormFile("db") + if err != nil { + jsonMsg(c, "", err) + return + } + defer file.Close() + err = database.ImportDB(file) + jsonMsg(c, "", err) +} + +func (a *ApiService) Logout(c *gin.Context) { + loginUser := GetLoginUser(c) + if loginUser != "" { + logger.Infof("user %s logout", loginUser) + } + ClearSession(c) + jsonMsg(c, "", nil) +} + +func (a *ApiService) LoadTokens() ([]byte, error) { + return a.UserService.LoadTokens() +} + +func (a *ApiService) GetTokens(c *gin.Context) { + loginUser := GetLoginUser(c) + tokens, err := a.UserService.GetUserTokens(loginUser) + jsonObj(c, tokens, err) +} + +func (a *ApiService) AddToken(c *gin.Context) { + loginUser := GetLoginUser(c) + expiry := c.Request.FormValue("expiry") + expiryInt, err := strconv.ParseInt(expiry, 10, 64) + if err != nil { + jsonMsg(c, "", err) + return + } + desc := c.Request.FormValue("desc") + token, err := a.UserService.AddToken(loginUser, expiryInt, desc) + jsonObj(c, token, err) +} + +func (a *ApiService) DeleteToken(c *gin.Context) { + tokenId := c.Request.FormValue("id") + err := a.UserService.DeleteToken(tokenId) + jsonMsg(c, "", err) +} + +func (a *ApiService) GetSingboxConfig(c *gin.Context) { + rawConfig, err := a.ConfigService.GetConfig("") + if err != nil { + c.Status(400) + c.Writer.WriteString(err.Error()) + return + } + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", "attachment; filename=config_"+time.Now().Format("20060102-150405")+".json") + c.Writer.Write(*rawConfig) +} + +func (a *ApiService) GetCheckOutbound(c *gin.Context) { + tag := c.Query("tag") + link := c.Query("link") + result := a.ConfigService.CheckOutbound(tag, link) + jsonObj(c, result, nil) +} diff --git a/api/apiV2Handler.go b/api/apiV2Handler.go new file mode 100644 index 0000000..8961d08 --- /dev/null +++ b/api/apiV2Handler.go @@ -0,0 +1,134 @@ +package api + +import ( + "encoding/json" + "time" + + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" + + "github.com/gin-gonic/gin" +) + +type TokenInMemory struct { + Token string + Expiry int64 + Username string +} + +type APIv2Handler struct { + ApiService + tokens *[]TokenInMemory +} + +func NewAPIv2Handler(g *gin.RouterGroup) *APIv2Handler { + a := &APIv2Handler{} + a.ReloadTokens() + a.initRouter(g) + return a +} + +func (a *APIv2Handler) initRouter(g *gin.RouterGroup) { + g.Use(func(c *gin.Context) { + a.checkToken(c) + }) + g.POST("/:postAction", a.postHandler) + g.GET("/:getAction", a.getHandler) +} + +func (a *APIv2Handler) postHandler(c *gin.Context) { + username := a.findUsername(c) + action := c.Param("postAction") + + switch action { + case "save": + a.ApiService.Save(c, username) + case "restartApp": + a.ApiService.RestartApp(c) + case "restartSb": + a.ApiService.RestartSb(c) + case "linkConvert": + a.ApiService.LinkConvert(c) + case "subConvert": + a.ApiService.SubConvert(c) + case "importdb": + a.ApiService.ImportDb(c) + default: + jsonMsg(c, "failed", common.NewError("unknown action: ", action)) + } +} + +func (a *APIv2Handler) getHandler(c *gin.Context) { + action := c.Param("getAction") + + switch action { + case "load": + a.ApiService.LoadData(c) + case "inbounds", "outbounds", "endpoints", "services", "tls", "clients", "config": + err := a.ApiService.LoadPartialData(c, []string{action}) + if err != nil { + jsonMsg(c, action, err) + } + return + case "users": + a.ApiService.GetUsers(c) + case "settings": + a.ApiService.GetSettings(c) + case "stats": + a.ApiService.GetStats(c) + case "status": + a.ApiService.GetStatus(c) + case "onlines": + a.ApiService.GetOnlines(c) + case "logs": + a.ApiService.GetLogs(c) + case "changes": + a.ApiService.CheckChanges(c) + case "keypairs": + a.ApiService.GetKeypairs(c) + case "getdb": + a.ApiService.GetDb(c) + case "checkOutbound": + a.ApiService.GetCheckOutbound(c) + default: + jsonMsg(c, "failed", common.NewError("unknown action: ", action)) + } +} + +func (a *APIv2Handler) findUsername(c *gin.Context) string { + token := c.Request.Header.Get("Token") + for index, t := range *a.tokens { + if t.Expiry > 0 && t.Expiry < time.Now().Unix() { + (*a.tokens) = append((*a.tokens)[:index], (*a.tokens)[index+1:]...) + continue + } + if t.Token == token { + return t.Username + } + } + return "" +} + +func (a *APIv2Handler) checkToken(c *gin.Context) { + username := a.findUsername(c) + if username != "" { + c.Next() + return + } + jsonMsg(c, "", common.NewError("invalid token")) + c.Abort() +} + +func (a *APIv2Handler) ReloadTokens() { + tokens, err := a.ApiService.LoadTokens() + if err == nil { + var newTokens []TokenInMemory + err = json.Unmarshal(tokens, &newTokens) + if err != nil { + logger.Error("unable to load tokens: ", err) + } + a.tokens = &newTokens + } else { + logger.Error("unable to load tokens: ", err) + } +} diff --git a/api/session.go b/api/session.go new file mode 100644 index 0000000..d683539 --- /dev/null +++ b/api/session.go @@ -0,0 +1,69 @@ +package api + +import ( + "encoding/gob" + + "github.com/alireza0/s-ui/database/model" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + loginUser = "LOGIN_USER" +) + +func init() { + gob.Register(model.User{}) +} + +func SetLoginUser(c *gin.Context, userName string, maxAge int) error { + options := sessions.Options{ + Path: "/", + Secure: false, + } + if maxAge > 0 { + options.MaxAge = maxAge * 60 + } + + s := sessions.Default(c) + s.Set(loginUser, userName) + s.Options(options) + + return s.Save() +} + +func SetMaxAge(c *gin.Context) error { + s := sessions.Default(c) + s.Options(sessions.Options{ + Path: "/", + }) + return s.Save() +} + +func GetLoginUser(c *gin.Context) string { + s := sessions.Default(c) + obj := s.Get(loginUser) + if obj == nil { + return "" + } + objStr, ok := obj.(string) + if !ok { + return "" + } + return objStr +} + +func IsLogin(c *gin.Context) bool { + return GetLoginUser(c) != "" +} + +func ClearSession(c *gin.Context) { + s := sessions.Default(c) + s.Clear() + s.Options(sessions.Options{ + Path: "/", + MaxAge: -1, + }) + s.Save() +} diff --git a/api/utils.go b/api/utils.go new file mode 100644 index 0000000..564f5aa --- /dev/null +++ b/api/utils.go @@ -0,0 +1,92 @@ +package api + +import ( + "net" + "net/http" + "strings" + + "github.com/alireza0/s-ui/logger" + + "github.com/gin-gonic/gin" +) + +type Msg struct { + Success bool `json:"success"` + Msg string `json:"msg"` + Obj interface{} `json:"obj"` +} + +func getRemoteIp(c *gin.Context) string { + value := c.GetHeader("X-Forwarded-For") + if value != "" { + ips := strings.Split(value, ",") + return ips[0] + } else { + addr := c.Request.RemoteAddr + ip, _, _ := net.SplitHostPort(addr) + return ip + } +} + +func getHostname(c *gin.Context) string { + host := c.Request.Host + if strings.Contains(host, ":") { + host, _, _ = net.SplitHostPort(c.Request.Host) + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + } + return host +} + +func jsonMsg(c *gin.Context, msg string, err error) { + jsonMsgObj(c, msg, nil, err) +} + +func jsonObj(c *gin.Context, obj interface{}, err error) { + jsonMsgObj(c, "", obj, err) +} + +func jsonMsgObj(c *gin.Context, msg string, obj interface{}, err error) { + m := Msg{ + Obj: obj, + } + if err == nil { + m.Success = true + if msg != "" { + m.Msg = msg + } + } else { + m.Success = false + m.Msg = msg + ": " + err.Error() + logger.Warning("failed :", err) + } + c.JSON(http.StatusOK, m) +} + +func pureJsonMsg(c *gin.Context, success bool, msg string) { + if success { + c.JSON(http.StatusOK, Msg{ + Success: true, + Msg: msg, + }) + } else { + c.JSON(http.StatusOK, Msg{ + Success: false, + Msg: msg, + }) + } +} + +func checkLogin(c *gin.Context) { + if !IsLogin(c) { + if c.GetHeader("X-Requested-With") == "XMLHttpRequest" { + pureJsonMsg(c, false, "Invalid login") + } else { + c.Redirect(http.StatusTemporaryRedirect, "/login") + } + c.Abort() + } else { + c.Next() + } +} diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..f61a654 --- /dev/null +++ b/app/app.go @@ -0,0 +1,128 @@ +package app + +import ( + "log" + + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/core" + "github.com/alireza0/s-ui/cronjob" + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/service" + "github.com/alireza0/s-ui/sub" + "github.com/alireza0/s-ui/web" + + "github.com/op/go-logging" +) + +type APP struct { + service.SettingService + configService *service.ConfigService + webServer *web.Server + subServer *sub.Server + cronJob *cronjob.CronJob + logger *logging.Logger + core *core.Core +} + +func NewApp() *APP { + return &APP{} +} + +func (a *APP) Init() error { + log.Printf("%v %v", config.GetName(), config.GetVersion()) + + a.initLog() + + err := database.InitDB(config.GetDBPath()) + if err != nil { + return err + } + + // Init Setting + a.SettingService.GetAllSetting() + + a.core = core.NewCore() + + a.cronJob = cronjob.NewCronJob() + a.webServer = web.NewServer() + a.subServer = sub.NewServer() + + a.configService = service.NewConfigService(a.core) + + return nil +} + +func (a *APP) Start() error { + loc, err := a.SettingService.GetTimeLocation() + if err != nil { + return err + } + + trafficAge, err := a.SettingService.GetTrafficAge() + if err != nil { + return err + } + + err = a.cronJob.Start(loc, trafficAge) + if err != nil { + return err + } + + err = a.webServer.Start() + if err != nil { + return err + } + + err = a.subServer.Start() + if err != nil { + return err + } + + err = a.configService.StartCore() + if err != nil { + logger.Error(err) + } + + return nil +} + +func (a *APP) Stop() { + a.cronJob.Stop() + err := a.subServer.Stop() + if err != nil { + logger.Warning("stop Sub Server err:", err) + } + err = a.webServer.Stop() + if err != nil { + logger.Warning("stop Web Server err:", err) + } + err = a.configService.StopCore() + if err != nil { + logger.Warning("stop Core err:", err) + } +} + +func (a *APP) initLog() { + switch config.GetLogLevel() { + case config.Debug: + logger.InitLogger(logging.DEBUG) + case config.Info: + logger.InitLogger(logging.INFO) + case config.Warn: + logger.InitLogger(logging.WARNING) + case config.Error: + logger.InitLogger(logging.ERROR) + default: + log.Fatal("unknown log level:", config.GetLogLevel()) + } +} + +func (a *APP) RestartApp() { + a.Stop() + a.Start() +} + +func (a *APP) GetCore() *core.Core { + return a.core +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..3493462 --- /dev/null +++ b/build.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +cd frontend +npm i +npm run build + +cd .. +echo "Backend" + +mkdir -p web/html +rm -fr web/html/* +cp -R frontend/dist/* web/html/ + +BUILD_TAGS="with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_naive_outbound,with_musl,badlinkname,tfogo_checklinkname0,with_tailscale" +go build -ldflags '-w -s -checklinkname=0 -extldflags "-Wl,-no_warn_duplicate_libraries"' -tags "$BUILD_TAGS" -o sui main.go diff --git a/cmd/admin.go b/cmd/admin.go new file mode 100644 index 0000000..a1d2dee --- /dev/null +++ b/cmd/admin.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/service" +) + +func resetAdmin() { + err := database.InitDB(config.GetDBPath()) + if err != nil { + fmt.Println(err) + return + } + + userService := service.UserService{} + err = userService.UpdateFirstUser("admin", "admin") + if err != nil { + fmt.Println("reset admin credentials failed:", err) + } else { + fmt.Println("reset admin credentials success") + } +} + +func updateAdmin(username string, password string) { + err := database.InitDB(config.GetDBPath()) + if err != nil { + fmt.Println(err) + return + } + + if username != "" || password != "" { + userService := service.UserService{} + err := userService.UpdateFirstUser(username, password) + if err != nil { + fmt.Println("reset admin credentials failed:", err) + } else { + fmt.Println("reset admin credentials success") + } + } +} + +func showAdmin() { + err := database.InitDB(config.GetDBPath()) + if err != nil { + fmt.Println(err) + return + } + userService := service.UserService{} + userModel, err := userService.GetFirstUser() + if err != nil { + fmt.Println("get current user info failed,error info:", err) + } + username := userModel.Username + userpasswd := userModel.Password + if (username == "") || (userpasswd == "") { + fmt.Println("current username or password is empty") + } + fmt.Println("First admin credentials:") + fmt.Println("\tUsername:\t", username) + fmt.Println("\tPassword:\t", userpasswd) +} diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..df99053 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "runtime/debug" + + "github.com/alireza0/s-ui/cmd/migration" + "github.com/alireza0/s-ui/config" +) + +func ParseCmd() { + var showVersion bool + flag.BoolVar(&showVersion, "v", false, "show version") + + adminCmd := flag.NewFlagSet("admin", flag.ExitOnError) + settingCmd := flag.NewFlagSet("setting", flag.ExitOnError) + + var username string + var password string + var port int + var path string + var subPort int + var subPath string + var reset bool + var show bool + settingCmd.BoolVar(&reset, "reset", false, "reset all settings") + settingCmd.BoolVar(&show, "show", false, "show current settings") + settingCmd.IntVar(&port, "port", 0, "set panel port") + settingCmd.StringVar(&path, "path", "", "set panel path") + settingCmd.IntVar(&subPort, "subPort", 0, "set sub port") + settingCmd.StringVar(&subPath, "subPath", "", "set sub path") + + adminCmd.BoolVar(&show, "show", false, "show first admin credentials") + adminCmd.BoolVar(&reset, "reset", false, "reset first admin credentials") + adminCmd.StringVar(&username, "username", "", "set login username") + adminCmd.StringVar(&password, "password", "", "set login password") + + oldUsage := flag.Usage + flag.Usage = func() { + oldUsage() + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" admin set/reset/show first admin credentials") + fmt.Println(" uri Show panel URI") + fmt.Println(" migrate migrate form older version") + fmt.Println(" setting set/reset/show settings") + fmt.Println() + adminCmd.Usage() + fmt.Println() + settingCmd.Usage() + } + + flag.Parse() + if showVersion { + fmt.Println("S-UI Panel\t", config.GetVersion()) + info, ok := debug.ReadBuildInfo() + if ok { + for _, dep := range info.Deps { + if dep.Path == "github.com/sagernet/sing-box" { + fmt.Println("Sing-Box\t", dep.Version) + break + } + } + } + return + } + + switch os.Args[1] { + case "admin": + err := adminCmd.Parse(os.Args[2:]) + if err != nil { + fmt.Println(err) + return + } + switch { + case show: + showAdmin() + case reset: + resetAdmin() + default: + updateAdmin(username, password) + showAdmin() + } + + case "uri": + getPanelURI() + + case "migrate": + migration.MigrateDb() + + case "setting": + err := settingCmd.Parse(os.Args[2:]) + if err != nil { + fmt.Println(err) + return + } + switch { + case show: + showSetting() + case reset: + resetSetting() + default: + updateSetting(port, path, subPort, subPath) + showSetting() + } + default: + fmt.Println("Invalid subcommands") + flag.Usage() + } +} diff --git a/cmd/migration/1_1.go b/cmd/migration/1_1.go new file mode 100644 index 0000000..b76435f --- /dev/null +++ b/cmd/migration/1_1.go @@ -0,0 +1,88 @@ +package migration + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/alireza0/s-ui/database/model" + + "gorm.io/gorm" +) + +func migrateClientSchema(db *gorm.DB) error { + rows, err := db.Raw("PRAGMA table_info(clients)").Rows() + if err != nil { + fmt.Println(err) + return err + } + defer rows.Close() + + for rows.Next() { + var ( + cid int + cname string + ctype string + notnull int + dfltValue interface{} + pk int + ) + + rows.Scan(&cid, &cname, &ctype, ¬null, &dfltValue, &pk) + if cname == "config" || cname == "inbounds" || cname == "links" { + if ctype == "text" { + fmt.Printf("Column %s has type TEXT\n", cname) + oldData := make([]struct { + Id uint + Data string + }, 0) + db.Model(model.Client{}).Select("id", cname+" as data").Scan(&oldData) + for _, data := range oldData { + var newData []byte + switch cname { + case "inbounds": + inbounds := strings.Split(data.Data, ",") + newData, _ = json.MarshalIndent(inbounds, "", " ") + case "config": + jsonData := map[string]interface{}{} + json.Unmarshal([]byte(data.Data), &jsonData) + newData, _ = json.MarshalIndent(jsonData, "", " ") + case "links": + jsonData := make([]interface{}, 0) + json.Unmarshal([]byte(data.Data), &jsonData) + newData, _ = json.MarshalIndent(jsonData, "", " ") + } + err = db.Model(model.Client{}).Where("id = ?", data.Id).UpdateColumn(cname, newData).Error + if err != nil { + return err + } + } + } + } + } + return nil +} + +func deleteOldWebSecret(db *gorm.DB) error { + return db.Exec("DELETE FROM settings WHERE key = ?", "webSecret").Error +} + +func changesObj(db *gorm.DB) error { + return db.Exec("UPDATE changes SET obj = CAST('\"' || CAST(obj AS TEXT) || '\"' AS BLOB) WHERE actor = ? and obj not like ?", "DepleteJob", "\"%\"").Error +} + +func to1_1(db *gorm.DB) error { + err := migrateClientSchema(db) + if err != nil { + return err + } + err = deleteOldWebSecret(db) + if err != nil { + return err + } + err = changesObj(db) + if err != nil { + return err + } + return nil +} diff --git a/cmd/migration/1_2.go b/cmd/migration/1_2.go new file mode 100644 index 0000000..9efc6c7 --- /dev/null +++ b/cmd/migration/1_2.go @@ -0,0 +1,317 @@ +package migration + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/alireza0/s-ui/database/model" + + "gorm.io/gorm" +) + +type InboundData struct { + Id uint + Tag string + Addrs json.RawMessage + OutJson json.RawMessage +} + +func moveJsonToDb(db *gorm.DB) error { + binFolderPath := os.Getenv("SUI_BIN_FOLDER") + if binFolderPath == "" { + binFolderPath = "bin" + } + dir, err := filepath.Abs(filepath.Dir(os.Args[0])) + if err != nil { + return err + } + configPath := dir + "/" + binFolderPath + "/config.json" + if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { + return nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + var oldConfig map[string]interface{} + err = json.Unmarshal(data, &oldConfig) + if err != nil { + return err + } + + oldInbounds := oldConfig["inbounds"].([]interface{}) + db.Migrator().DropTable(&model.Inbound{}) + db.AutoMigrate(&model.Inbound{}) + for _, inbound := range oldInbounds { + inbObj, _ := inbound.(map[string]interface{}) + tag, _ := inbObj["tag"].(string) + if tlsObj, ok := inbObj["tls"]; ok { + var tls_id uint + err = db.Raw("SELECT id FROM tls WHERE inbounds like ?", `%"`+tag+`"%`).Find(&tls_id).Error + if err != nil { + return err + } + + // Bind or Create tls_id + if tls_id > 0 { + inbObj["tls_id"] = tls_id + } else { + tls_server, _ := json.MarshalIndent(tlsObj, "", " ") + if len(tls_server) > 5 { + newTls := &model.Tls{ + Name: tag, + Server: tls_server, + Client: json.RawMessage("{}"), + } + err = db.Create(newTls).Error + if err != nil { + return err + } + inbObj["tls_id"] = newTls.Id + } + } + } + + var inbData InboundData + db.Raw("select id,addrs,out_json from inbound_data where tag = ?", tag).Find(&inbData) + if inbData.Id > 0 { + inbObj["out_json"] = inbData.OutJson + var addrs []map[string]interface{} + json.Unmarshal(inbData.Addrs, &addrs) + for index, addr := range addrs { + if tlsEnable, ok := addr["tls"].(bool); ok { + newTls := map[string]interface{}{ + "enabled": tlsEnable, + } + if insecure, ok := addr["insecure"].(bool); ok { + newTls["insecure"] = insecure + delete(addrs[index], "insecure") + } + if sni, ok := addr["server_name"].(string); ok { + newTls["server_name"] = sni + delete(addrs[index], "server_name") + } + addrs[index]["tls"] = newTls + } + } + inbObj["addrs"] = addrs + } else { + inbObj["out_json"] = json.RawMessage("{}") + inbObj["addrs"] = json.RawMessage("[]") + } + // Delete deprecated fields + delete(inbObj, "sniff") + delete(inbObj, "sniff_override_destination") + delete(inbObj, "sniff_timeout") + delete(inbObj, "domain_strategy") + inbJson, _ := json.Marshal(inbObj) + + var newInbound model.Inbound + err = newInbound.UnmarshalJSON(inbJson) + if err != nil { + return err + } + err = db.Create(&newInbound).Error + if err != nil { + return err + } + } + delete(oldConfig, "inbounds") + + blockOutboundTags := []string{} + dnsOutboundTags := []string{} + + oldOutbounds := oldConfig["outbounds"].([]interface{}) + db.Migrator().DropTable(&model.Outbound{}, &model.Endpoint{}) + db.AutoMigrate(&model.Outbound{}, &model.Endpoint{}) + for _, outbound := range oldOutbounds { + outType, _ := outbound.(map[string]interface{})["type"].(string) + outboundRaw, _ := json.MarshalIndent(outbound, "", " ") + if outType == "wireguard" { // Check if it is Entrypoint + var newEntrypoint model.Endpoint + err = newEntrypoint.UnmarshalJSON(outboundRaw) + if err != nil { + return err + } + err = db.Create(&newEntrypoint).Error + if err != nil { + return err + } + } else { // It is Outbound + var newOutbound model.Outbound + err = newOutbound.UnmarshalJSON(outboundRaw) + if err != nil { + return err + } + // Delete deprecated fields + if newOutbound.Type == "direct" { + var options map[string]interface{} + json.Unmarshal(newOutbound.Options, &options) + delete(options, "override_address") + delete(options, "override_port") + newOutbound.Options, _ = json.Marshal(options) + } + + switch newOutbound.Type { + case "dns": + dnsOutboundTags = append(dnsOutboundTags, newOutbound.Tag) + case "block": + blockOutboundTags = append(blockOutboundTags, newOutbound.Tag) + default: + err = db.Create(&newOutbound).Error + if err != nil { + return err + } + } + } + } + delete(oldConfig, "outbounds") + + // Check routing rules + if routingRules, ok := oldConfig["route"].(map[string]interface{}); ok { + if rules, hasRules := routingRules["rules"].([]interface{}); hasRules { + hasDns := false + for index, rule := range rules { + ruleObj, _ := rule.(map[string]interface{}) + isBlock := false + isDns := false + outboundTag, _ := ruleObj["outbound"].(string) + for _, tag := range blockOutboundTags { + if tag == outboundTag { + isBlock = true + delete(ruleObj, "outbound") + ruleObj["action"] = "reject" + break + } + } + for _, tag := range dnsOutboundTags { + if tag == outboundTag { + isDns = true + hasDns = true + delete(ruleObj, "outbound") + ruleObj["action"] = "hijack-dns" + break + } + } + if !isBlock && !isDns { + ruleObj["action"] = "route" + } + rules[index] = ruleObj + } + if hasDns { + rules = append(rules, map[string]interface{}{"action": "sniff"}) + } + routingRules["rules"] = rules + } + oldConfig["route"] = routingRules + } + + // Remove v2rayapi and clashapi from experimental config + experimental := oldConfig["experimental"].(map[string]interface{}) + delete(experimental, "v2ray_api") + delete(experimental, "clash_api") + oldConfig["experimental"] = experimental + + // Save the other configs + var otherConfigs json.RawMessage + otherConfigs, err = json.MarshalIndent(oldConfig, "", " ") + if err != nil { + return err + } + + return db.Save(&model.Setting{ + Key: "config", + Value: string(otherConfigs), + }).Error +} + +func migrateTls(db *gorm.DB) error { + if !db.Migrator().HasColumn(&model.Tls{}, "inbounds") { + return nil + } + err := db.Migrator().DropColumn(&model.Tls{}, "inbounds") + if err != nil { + return err + } + var tlsConfig []model.Tls + err = db.Model(model.Tls{}).Scan(&tlsConfig).Error + if err != nil { + return err + } + + for index, tls := range tlsConfig { + var tlsClient map[string]interface{} + err = json.Unmarshal(tls.Client, &tlsClient) + if err != nil { + continue + } + for key := range tlsClient { + switch key { + case "insecure", "disable_sni", "utls", "ech", "reality": + continue + default: + delete(tlsClient, key) + } + } + tlsConfig[index].Client, _ = json.MarshalIndent(tlsClient, "", " ") + } + + return db.Save(&tlsConfig).Error +} + +func dropInboundData(db *gorm.DB) error { + if !db.Migrator().HasTable(&InboundData{}) { + return nil + } + return db.Migrator().DropTable(&InboundData{}) +} + +func migrateClients(db *gorm.DB) error { + var oldClients []model.Client + err := db.Model(model.Client{}).Scan(&oldClients).Error + if err != nil { + return err + } + + for index, oldClient := range oldClients { + var old_inbounds []string + err = json.Unmarshal(oldClient.Inbounds, &old_inbounds) + if err != nil { + return err + } + var inbound_ids []uint + err = db.Raw("SELECT id FROM inbounds WHERE tag in ?", old_inbounds).Find(&inbound_ids).Error + if err != nil { + return err + } + oldClients[index].Inbounds, _ = json.Marshal(inbound_ids) + } + return db.Save(oldClients).Error +} + +func migrateChanges(db *gorm.DB) error { + return db.Migrator().DropColumn(&model.Changes{}, "index") +} + +func to1_2(db *gorm.DB) error { + err := moveJsonToDb(db) + if err != nil { + return err + } + err = migrateTls(db) + if err != nil { + return err + } + err = dropInboundData(db) + if err != nil { + return err + } + err = migrateClients(db) + if err != nil { + return err + } + return migrateChanges(db) +} diff --git a/cmd/migration/1_3.go b/cmd/migration/1_3.go new file mode 100644 index 0000000..a548d44 --- /dev/null +++ b/cmd/migration/1_3.go @@ -0,0 +1,155 @@ +package migration + +import ( + "encoding/json" + "net/url" + "strconv" + "strings" + + "github.com/alireza0/s-ui/database/model" + + "gorm.io/gorm" +) + +func migrate_dns(db *gorm.DB) error { + var configStr string + err := db.Model(model.Setting{}).Select("value").Where("key = ?", "config").First(&configStr).Error + if err != nil { + return err + } + if configStr == "" { + return nil + } + var config map[string]interface{} + err = json.Unmarshal([]byte(configStr), &config) + if err != nil { + return err + } + if dnsConfig, ok := config["dns"].(map[string]interface{}); ok { + if dnsServers, ok := dnsConfig["servers"].([]interface{}); ok { + for index, dnsServer := range dnsServers { + if dnsServer, ok := dnsServer.(map[string]interface{}); ok { + if addr, ok := dnsServer["address"].(string); ok && addr != "" { + switch addr { + case "local": + delete(dnsServer, "address") + dnsServer["type"] = "local" + case "fakeip": + delete(dnsServer, "address") + dnsServer["type"] = "fakeip" + default: + addrParsed, err := url.Parse(addr) + if err != nil { + continue + } + switch addrParsed.Scheme { + case "": + dnsServer["type"] = "udp" + dnsServer["server"] = addr + case "udp", "tcp", "tls", "quic", "https", "h3": + dnsServer["type"] = addrParsed.Scheme + dnsServer["server"] = addrParsed.Host + case "dhcp": + dnsServer["type"] = addrParsed.Scheme + if addrParsed.Host != "auto" && addrParsed.Host != "" { + dnsServer["interface"] = addrParsed.Host + } + case "rcode": + dnsServer["type"] = "predefined" + dnsServer["responses"] = []map[string]string{ + { + "rcode": strings.ToUpper(addrParsed.Host), + }, + } + } + delete(dnsServer, "address") + if addrParsed.Port() != "" { + port, err := strconv.Atoi(addrParsed.Port()) + if err == nil { + dnsServer["server_port"] = port + } + } + if address_resolver, ok := dnsServer["address_resolver"].(string); ok && address_resolver != "" { + delete(dnsServer, "address_resolver") + dnsServer["domain_resolver"] = address_resolver + } + delete(dnsServer, "strategy") + } + dnsServers[index] = dnsServer + } + } + } + dnsConfig["servers"] = dnsServers + } + config["dns"] = dnsConfig + } else { + return nil + } + + // save changes + configs, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return db.Model(model.Setting{}).Where("key = ?", "config").Update("value", string(configs)).Error +} + +func remove_outbound_strategy(db *gorm.DB) error { + var outbounds []model.Outbound + err := db.Find(&outbounds).Where("json_extract(options, '$.domain_strategy') IS NOT NULL").Error + if err != nil { + return err + } + for _, outbound := range outbounds { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(outbound.Options, &restFields); err != nil { + return err + } + delete(restFields, "domain_strategy") + outbound.Options, _ = json.MarshalIndent(restFields, "", " ") + db.Save(&outbound) + } + return nil +} + +func anytls_user_config(db *gorm.DB) error { + var clients []model.Client + err := db.Model(model.Client{}).Find(&clients).Error + if err != nil { + return err + } + for index, client := range clients { + var configs map[string]json.RawMessage + if err := json.Unmarshal(client.Config, &configs); err != nil { + return err + } + if configs["anytls"] != nil { + continue + } + configs["anytls"] = configs["trojan"] + configJson, err := json.MarshalIndent(configs, "", " ") + if err != nil { + return err + } + clients[index].Config = configJson + db.Save(&clients[index]) + } + return nil +} + +func to1_3(db *gorm.DB) error { + err := anytls_user_config(db) + if err != nil { + return err + } + err = migrate_dns(db) + if err != nil { + return err + } + err = remove_outbound_strategy(db) + if err != nil { + return err + } + return nil +} diff --git a/cmd/migration/main.go b/cmd/migration/main.go new file mode 100644 index 0000000..3e70ca2 --- /dev/null +++ b/cmd/migration/main.go @@ -0,0 +1,84 @@ +package migration + +import ( + "fmt" + "log" + "os" + + "github.com/alireza0/s-ui/config" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func MigrateDb() { + // void running on first install + path := config.GetDBPath() + _, err := os.Stat(path) + if err != nil { + println("Database not found") + return + } + + db, err := gorm.Open(sqlite.Open(path)) + if err != nil { + log.Fatal(err) + return + } + defer func() { + if sqlDB, e := db.DB(); e == nil { + _ = sqlDB.Close() + } + }() + tx := db.Begin() + defer func() { + if err == nil { + tx.Commit() + } else { + tx.Rollback() + } + }() + currentVersion := config.GetVersion() + dbVersion := "" + tx.Raw("SELECT value FROM settings WHERE key = ?", "version").Find(&dbVersion) + fmt.Println("Current version:", currentVersion, "\nDatabase version:", dbVersion) + + if currentVersion == dbVersion { + fmt.Println("Database is up to date, no need to migrate") + return + } + + fmt.Println("Start migrating database...") + + // Before 1.2 + if dbVersion == "" { + err = to1_1(tx) + if err != nil { + log.Fatal("Migration to 1.1 failed: ", err) + return + } + err = to1_2(tx) + if err != nil { + log.Fatal("Migration to 1.2 failed: ", err) + return + } + dbVersion = "1.2" + } + + // Before 1.3 + if dbVersion[0:3] == "1.2" { + err = to1_3(tx) + if err != nil { + log.Fatal("Migration to 1.3 failed: ", err) + return + } + } + + // Set version + err = tx.Exec("UPDATE settings SET value = ? WHERE key = ?", currentVersion, "version").Error + if err != nil { + log.Fatal("Update version failed: ", err) + return + } + fmt.Println("Migration done!") +} diff --git a/cmd/setting.go b/cmd/setting.go new file mode 100644 index 0000000..a090cc2 --- /dev/null +++ b/cmd/setting.go @@ -0,0 +1,217 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/service" + + "github.com/shirou/gopsutil/v4/net" +) + +func resetSetting() { + err := database.InitDB(config.GetDBPath()) + if err != nil { + fmt.Println(err) + return + } + + settingService := service.SettingService{} + err = settingService.ResetSettings() + if err != nil { + fmt.Println("reset setting failed:", err) + } else { + fmt.Println("reset setting success") + } +} + +func updateSetting(port int, path string, subPort int, subPath string) { + err := database.InitDB(config.GetDBPath()) + if err != nil { + fmt.Println(err) + return + } + + settingService := service.SettingService{} + + if port > 0 { + err := settingService.SetPort(port) + if err != nil { + fmt.Println("set port failed:", err) + } else { + fmt.Println("set port success") + } + } + if path != "" { + err := settingService.SetWebPath(path) + if err != nil { + fmt.Println("set path failed:", err) + } else { + fmt.Println("set path success") + } + } + if subPort > 0 { + err := settingService.SetSubPort(subPort) + if err != nil { + fmt.Println("set sub port failed:", err) + } else { + fmt.Println("set sub port success") + } + } + if subPath != "" { + err := settingService.SetSubPath(subPath) + if err != nil { + fmt.Println("set sub path failed:", err) + } else { + fmt.Println("set sub path success") + } + } +} + +func showSetting() { + err := database.InitDB(config.GetDBPath()) + if err != nil { + fmt.Println(err) + return + } + settingService := service.SettingService{} + allSetting, err := settingService.GetAllSetting() + if err != nil { + fmt.Println("get current port failed,error info:", err) + } + fmt.Println("Current panel settings:") + fmt.Println("\tPanel port:\t", (*allSetting)["webPort"]) + fmt.Println("\tPanel path:\t", (*allSetting)["webPath"]) + if (*allSetting)["webListen"] != "" { + fmt.Println("\tPanel IP:\t", (*allSetting)["webListen"]) + } + if (*allSetting)["webDomain"] != "" { + fmt.Println("\tPanel Domain:\t", (*allSetting)["webDomain"]) + } + if (*allSetting)["webURI"] != "" { + fmt.Println("\tPanel URI:\t", (*allSetting)["webURI"]) + } + fmt.Println() + fmt.Println("Current subscription settings:") + fmt.Println("\tSub port:\t", (*allSetting)["subPort"]) + fmt.Println("\tSub path:\t", (*allSetting)["subPath"]) + if (*allSetting)["subListen"] != "" { + fmt.Println("\tSub IP:\t", (*allSetting)["subListen"]) + } + if (*allSetting)["subDomain"] != "" { + fmt.Println("\tSub Domain:\t", (*allSetting)["subDomain"]) + } + if (*allSetting)["subURI"] != "" { + fmt.Println("\tSub URI:\t", (*allSetting)["subURI"]) + } +} + +func getPublicIP() string { + apis := []string{ + "https://api64.ipify.org", + "https://ip.sb", + "https://icanhazip.com", + "https://ipinfo.io/ip", + "https://checkip.amazonaws.com", + } + type result struct { + ip string + err error + } + ch := make(chan result, len(apis)) + var wg sync.WaitGroup + client := &http.Client{Timeout: 3 * time.Second} + + for _, api := range apis { + wg.Add(1) + go func(url string) { + defer wg.Done() + resp, err := client.Get(url) + if err != nil { + ch <- result{"", err} + return + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + ch <- result{"", err} + return + } + ch <- result{string(body), nil} + }(api) + } + + go func() { + wg.Wait() + close(ch) + }() + + for res := range ch { + if res.err == nil && res.ip != "" { + return strings.TrimSpace(res.ip) + } + } + return "" +} + +func getPanelURI() { + err := database.InitDB(config.GetDBPath()) + if err != nil { + fmt.Println(err) + return + } + settingService := service.SettingService{} + Port, _ := settingService.GetPort() + BasePath, _ := settingService.GetWebPath() + Listen, _ := settingService.GetListen() + Domain, _ := settingService.GetWebDomain() + KeyFile, _ := settingService.GetKeyFile() + CertFile, _ := settingService.GetCertFile() + TLS := false + if KeyFile != "" && CertFile != "" { + TLS = true + } + Proto := "" + if TLS { + Proto = "https://" + } else { + Proto = "http://" + } + PortText := fmt.Sprintf(":%d", Port) + if (Port == 443 && TLS) || (Port == 80 && !TLS) { + PortText = "" + } + if len(Domain) > 0 { + fmt.Println(Proto + Domain + PortText + BasePath) + return + } + if len(Listen) > 0 { + fmt.Println(Proto + Listen + PortText + BasePath) + return + } + fmt.Println("Local address:") + netInterfaces, _ := net.Interfaces() + for i := 0; i < len(netInterfaces); i++ { + if len(netInterfaces[i].Flags) > 2 && netInterfaces[i].Flags[0] == "up" && netInterfaces[i].Flags[1] != "loopback" { + addrs := netInterfaces[i].Addrs + for _, address := range addrs { + IP := strings.Split(address.Addr, "/")[0] + if strings.Contains(address.Addr, ".") { + fmt.Println(Proto + IP + PortText + BasePath) + } else if address.Addr[0:6] != "fe80::" { + fmt.Println(Proto + "[" + IP + "]" + PortText + BasePath) + } + } + } + } + pubIP := getPublicIP() + if pubIP != "" { + fmt.Printf("\nGlobal address:\n%s%s%s\n", Proto, pubIP, PortText+BasePath) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..53203d7 --- /dev/null +++ b/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +//go:embed version +var version string + +//go:embed name +var name string + +type LogLevel string + +const ( + Debug LogLevel = "debug" + Info LogLevel = "info" + Warn LogLevel = "warn" + Error LogLevel = "error" +) + +func GetVersion() string { + return strings.TrimSpace(version) +} + +func GetName() string { + return strings.TrimSpace(name) +} + +func GetLogLevel() LogLevel { + if IsDebug() { + return Debug + } + logLevel := os.Getenv("SUI_LOG_LEVEL") + if logLevel == "" { + return Info + } + return LogLevel(logLevel) +} + +func IsDebug() bool { + return os.Getenv("SUI_DEBUG") == "true" +} + +func GetDBFolderPath() string { + dbFolderPath := os.Getenv("SUI_DB_FOLDER") + if dbFolderPath == "" { + dir, err := filepath.Abs(filepath.Dir(os.Args[0])) + if err != nil { + // Cross-platform fallback path + if runtime.GOOS == "windows" { + return "C:\\Program Files\\s-ui\\db" + } + return "/usr/local/s-ui/db" + } + dbFolderPath = filepath.Join(dir, "db") + } + return dbFolderPath +} + +func GetDBPath() string { + return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName()) +} diff --git a/config/name b/config/name new file mode 100644 index 0000000..037c3b9 --- /dev/null +++ b/config/name @@ -0,0 +1 @@ +s-ui \ No newline at end of file diff --git a/config/version b/config/version new file mode 100644 index 0000000..c9929e3 --- /dev/null +++ b/config/version @@ -0,0 +1 @@ +1.4.2 \ No newline at end of file diff --git a/core/box.go b/core/box.go new file mode 100644 index 0000000..7e41ffd --- /dev/null +++ b/core/box.go @@ -0,0 +1,592 @@ +package core + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/alireza0/s-ui/util/common" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/certificate" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport/local" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/experimental/cachefile" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/direct" + "github.com/sagernet/sing-box/route" + sbCommon "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" +) + +var _ adapter.SimpleLifecycle = (*Box)(nil) + +type Box struct { + createdAt time.Time + logFactory log.Factory + logger log.ContextLogger + network *route.NetworkManager + endpoint *endpoint.Manager + inbound *inbound.Manager + outbound *outbound.Manager + service *boxService.Manager + dnsTransport *dns.TransportManager + dnsRouter *dns.Router + connection *route.ConnectionManager + router *route.Router + internalService []adapter.LifecycleService + statsTracker *StatsTracker + connTracker *ConnTracker + done chan struct{} +} + +type Options struct { + option.Options + Context context.Context +} + +func Context( + ctx context.Context, + inboundRegistry adapter.InboundRegistry, + outboundRegistry adapter.OutboundRegistry, + endpointRegistry adapter.EndpointRegistry, + dnsTransportRegistry adapter.DNSTransportRegistry, + serviceRegistry adapter.ServiceRegistry, +) context.Context { + if service.FromContext[option.InboundOptionsRegistry](ctx) == nil || + service.FromContext[adapter.InboundRegistry](ctx) == nil { + ctx = service.ContextWith[option.InboundOptionsRegistry](ctx, inboundRegistry) + ctx = service.ContextWith[adapter.InboundRegistry](ctx, inboundRegistry) + } + if service.FromContext[option.OutboundOptionsRegistry](ctx) == nil || + service.FromContext[adapter.OutboundRegistry](ctx) == nil { + ctx = service.ContextWith[option.OutboundOptionsRegistry](ctx, outboundRegistry) + ctx = service.ContextWith[adapter.OutboundRegistry](ctx, outboundRegistry) + } + if service.FromContext[option.EndpointOptionsRegistry](ctx) == nil || + service.FromContext[adapter.EndpointRegistry](ctx) == nil { + ctx = service.ContextWith[option.EndpointOptionsRegistry](ctx, endpointRegistry) + ctx = service.ContextWith[adapter.EndpointRegistry](ctx, endpointRegistry) + } + if service.FromContext[adapter.DNSTransportRegistry](ctx) == nil { + ctx = service.ContextWith[option.DNSTransportOptionsRegistry](ctx, dnsTransportRegistry) + ctx = service.ContextWith[adapter.DNSTransportRegistry](ctx, dnsTransportRegistry) + } + if service.FromContext[adapter.ServiceRegistry](ctx) == nil { + ctx = service.ContextWith[option.ServiceOptionsRegistry](ctx, serviceRegistry) + ctx = service.ContextWith[adapter.ServiceRegistry](ctx, serviceRegistry) + } + return ctx +} + +func NewBox(options Options) (*Box, error) { + var err error + createdAt := time.Now() + ctx := options.Context + if ctx == nil { + ctx = context.Background() + } + ctx = service.ContextWithDefaultRegistry(ctx) + + endpointRegistry := service.FromContext[adapter.EndpointRegistry](ctx) + inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) + outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx) + serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx) + + if endpointRegistry == nil { + return nil, common.NewError("missing endpoint registry in context") + } + if inboundRegistry == nil { + return nil, common.NewError("missing inbound registry in context") + } + if outboundRegistry == nil { + return nil, common.NewError("missing outbound registry in context") + } + if dnsTransportRegistry == nil { + return nil, common.NewError("missing DNS transport registry in context") + } + if serviceRegistry == nil { + return nil, common.NewError("missing service registry in context") + } + + ctx = pause.WithDefaultManager(ctx) + experimentalOptions := sbCommon.PtrValueOrDefault(options.Experimental) + var needCacheFile bool + var needClashAPI bool + var needV2RayAPI bool + if experimentalOptions.CacheFile != nil && experimentalOptions.CacheFile.Enabled { + needCacheFile = true + } + if experimentalOptions.ClashAPI != nil { + needClashAPI = true + } + if experimentalOptions.V2RayAPI != nil && experimentalOptions.V2RayAPI.Listen != "" { + needV2RayAPI = true + } + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + var defaultLogWriter io.Writer + if platformInterface != nil { + defaultLogWriter = io.Discard + } + var logFactory log.Factory + logFactory, err = NewFactory(log.Options{ + Context: ctx, + Options: sbCommon.PtrValueOrDefault(options.Log), + DefaultWriter: defaultLogWriter, + BaseTime: createdAt, + }) + if err != nil { + return nil, common.NewError("create log factory", err) + } + factory = logFactory + + var internalServices []adapter.LifecycleService + certificateOptions := sbCommon.PtrValueOrDefault(options.Certificate) + if C.IsAndroid || certificateOptions.Store != "" && certificateOptions.Store != C.CertificateStoreSystem || + len(certificateOptions.Certificate) > 0 || + len(certificateOptions.CertificatePath) > 0 || + len(certificateOptions.CertificateDirectoryPath) > 0 { + certificateStore, err := certificate.NewStore(ctx, logFactory.NewLogger("certificate"), certificateOptions) + if err != nil { + return nil, err + } + service.MustRegister[adapter.CertificateStore](ctx, certificateStore) + internalServices = append(internalServices, certificateStore) + } + + routeOptions := sbCommon.PtrValueOrDefault(options.Route) + dnsOptions := sbCommon.PtrValueOrDefault(options.DNS) + endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry) + inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager) + outboundManager := outbound.NewManager(logFactory.NewLogger("outbound"), outboundRegistry, endpointManager, routeOptions.Final) + dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final) + serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry) + + service.MustRegister[adapter.EndpointManager](ctx, endpointManager) + service.MustRegister[adapter.InboundManager](ctx, inboundManager) + service.MustRegister[adapter.OutboundManager](ctx, outboundManager) + service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager) + service.MustRegister[adapter.ServiceManager](ctx, serviceManager) + + dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions) + service.MustRegister[adapter.DNSRouter](ctx, dnsRouter) + + networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions) + if err != nil { + return nil, common.NewError("initialize network manager", err) + } + service.MustRegister[adapter.NetworkManager](ctx, networkManager) + connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection")) + service.MustRegister[adapter.ConnectionManager](ctx, connectionManager) + router := route.NewRouter(ctx, logFactory, routeOptions, dnsOptions) + service.MustRegister[adapter.Router](ctx, router) + err = router.Initialize(routeOptions.Rules, routeOptions.RuleSet) + if err != nil { + return nil, common.NewError("initialize router", err) + } + for i, transportOptions := range dnsOptions.Servers { + var tag string + if transportOptions.Tag != "" { + tag = transportOptions.Tag + } else { + tag = F.ToString(i) + } + err = dnsTransportManager.Create( + ctx, + logFactory.NewLogger(F.ToString("dns/", transportOptions.Type, "[", tag, "]")), + tag, + transportOptions.Type, + transportOptions.Options, + ) + if err != nil { + return nil, common.NewError("initialize DNS server[", i, "]", err) + } + } + err = dnsRouter.Initialize(dnsOptions.Rules) + if err != nil { + return nil, common.NewError("initialize dns router", err) + } + for i, endpointOptions := range options.Endpoints { + var tag string + if endpointOptions.Tag != "" { + tag = endpointOptions.Tag + } else { + tag = F.ToString(i) + } + err = endpointManager.Create( + ctx, + router, + logFactory.NewLogger(F.ToString("endpoint/", endpointOptions.Type, "[", tag, "]")), + tag, + endpointOptions.Type, + endpointOptions.Options, + ) + if err != nil { + return nil, common.NewError("initialize endpoint["+F.ToString(i)+"] "+tag, err) + } + } + for i, inboundOptions := range options.Inbounds { + var tag string + if inboundOptions.Tag != "" { + tag = inboundOptions.Tag + } else { + tag = F.ToString(i) + } + err = inboundManager.Create( + ctx, + router, + logFactory.NewLogger(F.ToString("inbound/", inboundOptions.Type, "[", tag, "]")), + tag, + inboundOptions.Type, + inboundOptions.Options, + ) + if err != nil { + return nil, common.NewError("initialize inbound[", i, "] ", tag, err) + } + } + for i, outboundOptions := range options.Outbounds { + var tag string + if outboundOptions.Tag != "" { + tag = outboundOptions.Tag + } else { + tag = F.ToString(i) + } + outboundCtx := ctx + if tag != "" { + // TODO: remove this + outboundCtx = adapter.WithContext(outboundCtx, &adapter.InboundContext{ + Outbound: tag, + }) + } + err = outboundManager.Create( + outboundCtx, + router, + logFactory.NewLogger(F.ToString("outbound/", outboundOptions.Type, "[", tag, "]")), + tag, + outboundOptions.Type, + outboundOptions.Options, + ) + if err != nil { + return nil, common.NewError("initialize outbound["+F.ToString(i)+"] "+tag, err) + } + } + for i, serviceOptions := range options.Services { + var tag string + if serviceOptions.Tag != "" { + tag = serviceOptions.Tag + } else { + tag = F.ToString(i) + } + err = serviceManager.Create( + ctx, + logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")), + tag, + serviceOptions.Type, + serviceOptions.Options, + ) + if err != nil { + return nil, common.NewError("initialize service["+F.ToString(i)+"]"+tag, err) + } + } + outboundManager.Initialize(func() (adapter.Outbound, error) { + return direct.NewOutbound( + ctx, + router, + logFactory.NewLogger("outbound/direct"), + "direct", + option.DirectOutboundOptions{}, + ) + }) + dnsTransportManager.Initialize(func() (adapter.DNSTransport, error) { + return local.NewTransport( + ctx, + logFactory.NewLogger("dns/local"), + "local", + option.LocalDNSServerOptions{}, + ) + }) + if platformInterface != nil { + err = platformInterface.Initialize(networkManager) + if err != nil { + return nil, common.NewError("initialize platform interface", err) + } + } + statsTracker := NewStatsTracker() + connTracker := NewConnTracker() + router.AppendTracker(statsTracker) + router.AppendTracker(connTracker) + + if needCacheFile { + cacheFile := cachefile.New(ctx, sbCommon.PtrValueOrDefault(experimentalOptions.CacheFile)) + service.MustRegister[adapter.CacheFile](ctx, cacheFile) + internalServices = append(internalServices, cacheFile) + } + if needClashAPI { + clashAPIOptions := sbCommon.PtrValueOrDefault(experimentalOptions.ClashAPI) + clashAPIOptions.ModeList = experimental.CalculateClashModeList(options.Options) + clashServer, err := experimental.NewClashServer(ctx, logFactory.(log.ObservableFactory), clashAPIOptions) + if err != nil { + return nil, common.NewError(err, "create clash-server") + } + router.AppendTracker(clashServer) + service.MustRegister[adapter.ClashServer](ctx, clashServer) + internalServices = append(internalServices, clashServer) + } + if needV2RayAPI { + v2rayServer, err := experimental.NewV2RayServer(logFactory.NewLogger("v2ray-api"), sbCommon.PtrValueOrDefault(experimentalOptions.V2RayAPI)) + if err != nil { + return nil, common.NewError(err, "create v2ray-server") + } + if v2rayServer.StatsService() != nil { + router.AppendTracker(v2rayServer.StatsService()) + internalServices = append(internalServices, v2rayServer) + service.MustRegister[adapter.V2RayServer](ctx, v2rayServer) + } + } + ntpOptions := sbCommon.PtrValueOrDefault(options.NTP) + if ntpOptions.Enabled { + ntpDialer, err := dialer.New(ctx, ntpOptions.DialerOptions, ntpOptions.ServerIsDomain()) + if err != nil { + return nil, common.NewError(err, "create NTP service") + } + timeService := ntp.NewService(ntp.Options{ + Context: ctx, + Dialer: ntpDialer, + Logger: logFactory.NewLogger("ntp"), + Server: ntpOptions.ServerOptions.Build(), + Interval: time.Duration(ntpOptions.Interval), + WriteToSystem: ntpOptions.WriteToSystem, + }) + service.MustRegister[ntp.TimeService](ctx, timeService) + internalServices = append(internalServices, adapter.NewLifecycleService(timeService, "ntp service")) + } + return &Box{ + network: networkManager, + endpoint: endpointManager, + inbound: inboundManager, + outbound: outboundManager, + dnsTransport: dnsTransportManager, + service: serviceManager, + dnsRouter: dnsRouter, + connection: connectionManager, + router: router, + createdAt: createdAt, + logFactory: logFactory, + logger: logFactory.Logger(), + internalService: internalServices, + statsTracker: statsTracker, + connTracker: connTracker, + done: make(chan struct{}), + }, nil +} + +func (s *Box) PreStart() error { + err := s.preStart() + if err != nil { + // TODO: remove catch error + defer func() { + v := recover() + if v != nil { + s.logger.Error(err.Error()) + s.logger.Error("panic on early close: " + fmt.Sprint(v)) + } + }() + s.Close() + return err + } + s.logger.Info("sing-box pre-started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") + return nil +} + +func (s *Box) Start() error { + err := s.start() + if err != nil { + return err + } + s.logger.Info("sing-box started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") + return nil +} + +func (s *Box) preStart() error { + monitor := taskmonitor.New(s.logger, C.StartTimeout) + monitor.Start("start logger") + err := s.logFactory.Start() + monitor.Finish() + if err != nil { + return common.NewError(err, "start logger") + } + err = adapter.StartNamed(s.logger, adapter.StartStateInitialize, s.internalService) // cache-file clash-api v2ray-api + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) + if err != nil { + return err + } + return nil +} + +func (s *Box) start() error { + err := s.preStart() + if err != nil { + return err + } + err = adapter.StartNamed(s.logger, adapter.StartStateStart, s.internalService) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.endpoint, s.service) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.service) + if err != nil { + return err + } + err = adapter.StartNamed(s.logger, adapter.StartStatePostStart, s.internalService) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + if err != nil { + return err + } + err = adapter.StartNamed(s.logger, adapter.StartStateStarted, s.internalService) + if err != nil { + return err + } + return nil +} + +func (s *Box) Close() error { + select { + case <-s.done: + return nil + default: + close(s.done) + } + var err error + s.logger.Info("closing sing-box") + for _, closeItem := range []struct { + name string + service adapter.Lifecycle + }{ + {"service", s.service}, + {"endpoint", s.endpoint}, + {"inbound", s.inbound}, + {"outbound", s.outbound}, + {"router", s.router}, + {"connection", s.connection}, + {"dns-router", s.dnsRouter}, + {"dns-transport", s.dnsTransport}, + {"network", s.network}, + } { + if closeItem.service == nil { + continue + } + func() { + defer func() { + if v := recover(); v != nil { + err = errors.Join(err, common.NewError(fmt.Errorf("panic: %v", v), "close "+closeItem.name)) + s.logger.Error("panic closing ", closeItem.name, ": ", v) + } + }() + s.logger.Trace("close ", closeItem.name) + startTime := time.Now() + closeErr := closeItem.service.Close() + if closeErr != nil { + closeErr = common.NewError(closeErr, "close "+closeItem.name) + } + err = errors.Join(err, closeErr) + s.logger.Trace("close ", closeItem.name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + }() + } + for _, lifecycleService := range s.internalService { + if lifecycleService == nil { + continue + } + func() { + defer func() { + if v := recover(); v != nil { + err = errors.Join(err, common.NewError(fmt.Errorf("panic: %v", v), "close "+lifecycleService.Name())) + s.logger.Error("panic closing ", lifecycleService.Name(), ": ", v) + } + }() + s.logger.Trace("close ", lifecycleService.Name()) + startTime := time.Now() + closeErr := lifecycleService.Close() + if closeErr != nil { + closeErr = common.NewError(closeErr, "close "+lifecycleService.Name()) + } + err = errors.Join(err, closeErr) + s.logger.Trace("close ", lifecycleService.Name(), " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + }() + } + s.logger.Trace("close logger") + startTime := time.Now() + closeErr := s.logFactory.Close() + if closeErr != nil { + closeErr = common.NewError(closeErr, "close logger") + } + err = errors.Join(err, closeErr) + s.logger.Trace("close logger completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + s.logger.Info("sing-box closed (live time: ", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") + if s.statsTracker != nil { + s.statsTracker.Reset() + } + if s.connTracker != nil { + s.connTracker.Reset() + } + return err +} + +func (s *Box) Uptime() uint32 { + return uint32(time.Since(s.createdAt).Seconds()) +} + +func (s *Box) Network() adapter.NetworkManager { + return s.network +} + +func (s *Box) Router() adapter.Router { + return s.router +} + +func (s *Box) Inbound() adapter.InboundManager { + return s.inbound +} + +func (s *Box) Outbound() adapter.OutboundManager { + return s.outbound +} + +func (s *Box) Endpoint() adapter.EndpointManager { + return s.endpoint +} + +func (s *Box) StatsTracker() *StatsTracker { + return s.statsTracker +} + +func (s *Box) ConnTracker() *ConnTracker { + return s.connTracker +} diff --git a/core/endpoint.go b/core/endpoint.go new file mode 100644 index 0000000..70987c6 --- /dev/null +++ b/core/endpoint.go @@ -0,0 +1,147 @@ +package core + +import ( + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" +) + +func (c *Core) AddInbound(config []byte) error { + if !c.isRunning { + return common.NewError("sing-box is not running") + } + var err error + var inbound_config option.Inbound + err = inbound_config.UnmarshalJSONContext(c.GetCtx(), config) + if err != nil { + return err + } + + err = inbound_manager.Create( + c.GetCtx(), + router, + factory.NewLogger("inbound/"+inbound_config.Type+"["+inbound_config.Tag+"]"), + inbound_config.Tag, + inbound_config.Type, + inbound_config.Options) + if err != nil { + return err + } + + return nil +} + +func (c *Core) RemoveInbound(tag string) error { + if !c.isRunning { + return common.NewError("sing-box is not running") + } + logger.Info("remove inbound: ", tag) + return inbound_manager.Remove(tag) +} + +func (c *Core) AddOutbound(config []byte) error { + if !c.isRunning { + return common.NewError("sing-box is not running") + } + var err error + var outbound_config option.Outbound + + err = outbound_config.UnmarshalJSONContext(c.GetCtx(), config) + if err != nil { + return err + } + + outboundCtx := adapter.WithContext(c.GetCtx(), &adapter.InboundContext{ + Outbound: outbound_config.Tag, + }) + + err = outbound_manager.Create( + outboundCtx, + router, + factory.NewLogger("outbound/"+outbound_config.Type+"["+outbound_config.Tag+"]"), + outbound_config.Tag, + outbound_config.Type, + outbound_config.Options) + if err != nil { + return err + } + + return nil +} + +func (c *Core) RemoveOutbound(tag string) error { + if !c.isRunning { + return common.NewError("sing-box is not running") + } + logger.Info("remove outbound: ", tag) + return outbound_manager.Remove(tag) +} + +func (c *Core) AddEndpoint(config []byte) error { + if !c.isRunning { + return common.NewError("sing-box is not running") + } + var err error + var endpoint_config option.Endpoint + + err = endpoint_config.UnmarshalJSONContext(c.GetCtx(), config) + if err != nil { + return err + } + + err = endpoint_manager.Create( + c.GetCtx(), + router, + factory.NewLogger("endpoint/"+endpoint_config.Type+"["+endpoint_config.Tag+"]"), + endpoint_config.Tag, + endpoint_config.Type, + endpoint_config.Options) + if err != nil { + return err + } + + return nil +} + +func (c *Core) RemoveEndpoint(tag string) error { + if !c.isRunning { + return common.NewError("sing-box is not running") + } + logger.Info("remove endpoint: ", tag) + return endpoint_manager.Remove(tag) +} + +func (c *Core) AddService(config []byte) error { + if !c.isRunning { + return common.NewError("sing-box is not running") + } + var err error + var srv_config option.Service + + err = srv_config.UnmarshalJSONContext(c.GetCtx(), config) + if err != nil { + return err + } + + err = service_manager.Create( + c.GetCtx(), + factory.NewLogger("service/"+srv_config.Type+"["+srv_config.Tag+"]"), + srv_config.Tag, + srv_config.Type, + srv_config.Options) + if err != nil { + return err + } + + return nil +} + +func (c *Core) RemoveService(tag string) error { + if !c.isRunning { + return common.NewError("sing-box is not running") + } + logger.Info("remove service: ", tag) + return service_manager.Remove(tag) +} diff --git a/core/log.go b/core/log.go new file mode 100644 index 0000000..43ee65e --- /dev/null +++ b/core/log.go @@ -0,0 +1,242 @@ +package core + +import ( + "context" + "io" + "os" + "time" + + suiLog "github.com/alireza0/s-ui/logger" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/observable" + "github.com/sagernet/sing/service/filemanager" +) + +type PlatformWriter struct{} + +func (p PlatformWriter) DisableColors() bool { + return true +} +func (p PlatformWriter) WriteMessage(level log.Level, message string) { + switch level { + case log.LevelInfo: + suiLog.Info(message) + case log.LevelWarn: + suiLog.Warning(message) + case log.LevelPanic: + case log.LevelFatal: + case log.LevelError: + suiLog.Error(message) + default: + suiLog.Debug(message) + } +} + +func NewFactory(options log.Options) (log.Factory, error) { + logOptions := options.Options + + if logOptions.Disabled { + return log.NewNOPFactory(), nil + } + + var logWriter io.Writer + var logFilePath string + + switch logOptions.Output { + case "": + logWriter = options.DefaultWriter + if logWriter == nil { + logWriter = os.Stderr + } + case "stderr": + logWriter = os.Stderr + case "stdout": + logWriter = os.Stdout + default: + logFilePath = logOptions.Output + } + logFormatter := log.Formatter{ + BaseTime: options.BaseTime, + DisableColors: logOptions.DisableColor || logFilePath != "", + DisableTimestamp: !logOptions.Timestamp && logFilePath != "", + FullTimestamp: logOptions.Timestamp, + TimestampFormat: "-0700 2006-01-02 15:04:05", + } + factory := NewDefaultFactory( + options.Context, + logFormatter, + logWriter, + logFilePath, + ) + if logOptions.Level != "" { + logLevel, err := log.ParseLevel(logOptions.Level) + if err != nil { + return nil, common.Error("parse log level", err) + } + factory.SetLevel(logLevel) + } else { + factory.SetLevel(log.LevelTrace) + } + return factory, nil +} + +var _ log.Factory = (*defaultFactory)(nil) + +type defaultFactory struct { + ctx context.Context + formatter log.Formatter + writer io.Writer + file *os.File + filePath string + level log.Level + subscriber *observable.Subscriber[log.Entry] + observer *observable.Observer[log.Entry] +} + +func NewDefaultFactory( + ctx context.Context, + formatter log.Formatter, + writer io.Writer, + filePath string, +) log.ObservableFactory { + factory := &defaultFactory{ + ctx: ctx, + formatter: formatter, + writer: writer, + filePath: filePath, + level: log.LevelTrace, + subscriber: observable.NewSubscriber[log.Entry](128), + } + return factory +} + +func (f *defaultFactory) Start() error { + if f.filePath != "" { + logFile, err := filemanager.OpenFile(f.ctx, f.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + f.writer = logFile + f.file = logFile + } + return nil +} + +func (f *defaultFactory) Close() error { + return common.Close( + common.PtrOrNil(f.file), + f.subscriber, + ) +} + +func (f *defaultFactory) Level() log.Level { + return f.level +} + +func (f *defaultFactory) SetLevel(level log.Level) { + f.level = level +} + +func (f *defaultFactory) Logger() log.ContextLogger { + return f.NewLogger("") +} + +func (f *defaultFactory) NewLogger(tag string) log.ContextLogger { + return &observableLogger{f, tag} +} + +func (f *defaultFactory) Subscribe() (subscription observable.Subscription[log.Entry], done <-chan struct{}, err error) { + return f.observer.Subscribe() +} + +func (f *defaultFactory) UnSubscribe(sub observable.Subscription[log.Entry]) { + f.observer.UnSubscribe(sub) +} + +type observableLogger struct { + *defaultFactory + tag string +} + +func (l *observableLogger) Log(ctx context.Context, level log.Level, args []any) { + level = log.OverrideLevelFromContext(level, ctx) + if level > l.level { + return + } + msg := F.ToString(args...) + switch level { + case log.LevelInfo: + suiLog.Info(l.tag, msg) + case log.LevelWarn: + suiLog.Warning(l.tag, msg) + case log.LevelPanic: + case log.LevelFatal: + case log.LevelError: + suiLog.Error(l.tag, msg) + default: + suiLog.Debug(l.tag, msg) + } + if (l.filePath != "" || l.writer != os.Stderr) && l.writer != nil { + message := l.formatter.Format(ctx, level, l.tag, msg, time.Now()) + l.writer.Write([]byte(message)) + } +} + +func (l *observableLogger) Trace(args ...any) { + l.TraceContext(context.Background(), args...) +} + +func (l *observableLogger) Debug(args ...any) { + l.DebugContext(context.Background(), args...) +} + +func (l *observableLogger) Info(args ...any) { + l.InfoContext(context.Background(), args...) +} + +func (l *observableLogger) Warn(args ...any) { + l.WarnContext(context.Background(), args...) +} + +func (l *observableLogger) Error(args ...any) { + l.ErrorContext(context.Background(), args...) +} + +func (l *observableLogger) Fatal(args ...any) { + l.FatalContext(context.Background(), args...) +} + +func (l *observableLogger) Panic(args ...any) { + l.PanicContext(context.Background(), args...) +} + +func (l *observableLogger) TraceContext(ctx context.Context, args ...any) { + l.Log(ctx, log.LevelTrace, args) +} + +func (l *observableLogger) DebugContext(ctx context.Context, args ...any) { + l.Log(ctx, log.LevelDebug, args) +} + +func (l *observableLogger) InfoContext(ctx context.Context, args ...any) { + l.Log(ctx, log.LevelInfo, args) +} + +func (l *observableLogger) WarnContext(ctx context.Context, args ...any) { + l.Log(ctx, log.LevelWarn, args) +} + +func (l *observableLogger) ErrorContext(ctx context.Context, args ...any) { + l.Log(ctx, log.LevelError, args) +} + +func (l *observableLogger) FatalContext(ctx context.Context, args ...any) { + l.Log(ctx, log.LevelFatal, args) +} + +func (l *observableLogger) PanicContext(ctx context.Context, args ...any) { + l.Log(ctx, log.LevelPanic, args) +} diff --git a/core/main.go b/core/main.go new file mode 100644 index 0000000..61aad08 --- /dev/null +++ b/core/main.go @@ -0,0 +1,95 @@ +package core + +import ( + "context" + + "github.com/alireza0/s-ui/logger" + + sb "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + _ "github.com/sagernet/sing-box/experimental/clashapi" + _ "github.com/sagernet/sing-box/experimental/v2rayapi" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + _ "github.com/sagernet/sing-box/transport/v2rayquic" + "github.com/sagernet/sing/service" +) + +var ( + globalCtx context.Context + inbound_manager adapter.InboundManager + outbound_manager adapter.OutboundManager + service_manager adapter.ServiceManager + endpoint_manager adapter.EndpointManager + router adapter.Router + factory log.Factory +) + +type Core struct { + isRunning bool + instance *Box +} + +func NewCore() *Core { + globalCtx = context.Background() + globalCtx = sb.Context(globalCtx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry()) + return &Core{ + isRunning: false, + instance: nil, + } +} + +func (c *Core) GetCtx() context.Context { + return globalCtx +} + +func (c *Core) GetInstance() *Box { + return c.instance +} + +func (c *Core) Start(sbConfig []byte) error { + var opt option.Options + err := opt.UnmarshalJSONContext(globalCtx, sbConfig) + if err != nil { + logger.Error("Unmarshal config err:", err.Error()) + } + + c.instance, err = NewBox(Options{ + Context: globalCtx, + Options: opt, + }) + if err != nil { + return err + } + + err = c.instance.Start() + if err != nil { + _ = c.instance.Close() + c.instance = nil + return err + } + + globalCtx = service.ContextWith(globalCtx, c) + inbound_manager = service.FromContext[adapter.InboundManager](globalCtx) + outbound_manager = service.FromContext[adapter.OutboundManager](globalCtx) + service_manager = service.FromContext[adapter.ServiceManager](globalCtx) + endpoint_manager = service.FromContext[adapter.EndpointManager](globalCtx) + router = service.FromContext[adapter.Router](globalCtx) + + c.isRunning = true + return nil +} + +func (c *Core) Stop() error { + c.isRunning = false + if c.instance == nil { + return nil + } + err := c.instance.Close() + c.instance = nil + return err +} + +func (c *Core) IsRunning() bool { + return c.isRunning +} diff --git a/core/outbound_check.go b/core/outbound_check.go new file mode 100644 index 0000000..a91b512 --- /dev/null +++ b/core/outbound_check.go @@ -0,0 +1,40 @@ +package core + +import ( + "context" + "time" + + urltest "github.com/sagernet/sing-box/common/urltest" +) + +const checkTimeout = 15 * time.Second + +type CheckOutboundResult struct { + OK bool + Delay uint16 + Error string +} + +func CheckOutbound(ctx context.Context, tag string, link string) (result CheckOutboundResult) { + if outbound_manager == nil { + result.Error = "core not running" + return result + } + ob, ok := outbound_manager.Outbound(tag) + if !ok { + result.Error = "outbound not found" + return result + } + + ctx, cancel := context.WithTimeout(ctx, checkTimeout) + defer cancel() + + delay, err := urltest.URLTest(ctx, link, ob) + if err != nil { + result.Error = err.Error() + return result + } + result.OK = true + result.Delay = delay + return result +} diff --git a/core/register.go b/core/register.go new file mode 100644 index 0000000..ff9d4f6 --- /dev/null +++ b/core/register.go @@ -0,0 +1,139 @@ +package core + +import ( + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/dns/transport/dhcp" + "github.com/sagernet/sing-box/dns/transport/fakeip" + "github.com/sagernet/sing-box/dns/transport/hosts" + "github.com/sagernet/sing-box/dns/transport/local" + "github.com/sagernet/sing-box/dns/transport/quic" + "github.com/sagernet/sing-box/protocol/anytls" + "github.com/sagernet/sing-box/protocol/block" + "github.com/sagernet/sing-box/protocol/direct" + "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing-box/protocol/http" + "github.com/sagernet/sing-box/protocol/hysteria" + "github.com/sagernet/sing-box/protocol/hysteria2" + "github.com/sagernet/sing-box/protocol/mixed" + "github.com/sagernet/sing-box/protocol/naive" + _ "github.com/sagernet/sing-box/protocol/naive/quic" + "github.com/sagernet/sing-box/protocol/redirect" + "github.com/sagernet/sing-box/protocol/shadowsocks" + "github.com/sagernet/sing-box/protocol/shadowtls" + "github.com/sagernet/sing-box/protocol/socks" + "github.com/sagernet/sing-box/protocol/ssh" + "github.com/sagernet/sing-box/protocol/tor" + "github.com/sagernet/sing-box/protocol/trojan" + "github.com/sagernet/sing-box/protocol/tuic" + "github.com/sagernet/sing-box/protocol/tun" + "github.com/sagernet/sing-box/protocol/vless" + "github.com/sagernet/sing-box/protocol/vmess" + "github.com/sagernet/sing-box/protocol/wireguard" + "github.com/sagernet/sing-box/service/ccm" + "github.com/sagernet/sing-box/service/ocm" + "github.com/sagernet/sing-box/service/resolved" + "github.com/sagernet/sing-box/service/ssmapi" + _ "github.com/sagernet/sing-box/transport/v2rayquic" +) + +func InboundRegistry() *inbound.Registry { + registry := inbound.NewRegistry() + + tun.RegisterInbound(registry) + redirect.RegisterRedirect(registry) + redirect.RegisterTProxy(registry) + direct.RegisterInbound(registry) + + socks.RegisterInbound(registry) + http.RegisterInbound(registry) + mixed.RegisterInbound(registry) + + shadowsocks.RegisterInbound(registry) + vmess.RegisterInbound(registry) + trojan.RegisterInbound(registry) + naive.RegisterInbound(registry) + shadowtls.RegisterInbound(registry) + vless.RegisterInbound(registry) + anytls.RegisterInbound(registry) + + hysteria.RegisterInbound(registry) + tuic.RegisterInbound(registry) + hysteria2.RegisterInbound(registry) + + return registry +} + +func OutboundRegistry() *outbound.Registry { + registry := outbound.NewRegistry() + + direct.RegisterOutbound(registry) + + block.RegisterOutbound(registry) + + group.RegisterSelector(registry) + group.RegisterURLTest(registry) + + socks.RegisterOutbound(registry) + http.RegisterOutbound(registry) + shadowsocks.RegisterOutbound(registry) + vmess.RegisterOutbound(registry) + trojan.RegisterOutbound(registry) + registerNaiveOutbound(registry) + tor.RegisterOutbound(registry) + ssh.RegisterOutbound(registry) + shadowtls.RegisterOutbound(registry) + vless.RegisterOutbound(registry) + anytls.RegisterOutbound(registry) + + hysteria.RegisterOutbound(registry) + tuic.RegisterOutbound(registry) + hysteria2.RegisterOutbound(registry) + + return registry +} + +func EndpointRegistry() *endpoint.Registry { + registry := endpoint.NewRegistry() + + wireguard.RegisterEndpoint(registry) + registerTailscaleEndpoint(registry) + + return registry +} + +func DNSTransportRegistry() *dns.TransportRegistry { + registry := dns.NewTransportRegistry() + + transport.RegisterTCP(registry) + transport.RegisterUDP(registry) + transport.RegisterTLS(registry) + transport.RegisterHTTPS(registry) + hosts.RegisterTransport(registry) + local.RegisterTransport(registry) + fakeip.RegisterTransport(registry) + + quic.RegisterTransport(registry) + quic.RegisterHTTP3Transport(registry) + dhcp.RegisterTransport(registry) + registerTailscaleTransport(registry) + + return registry +} + +func ServiceRegistry() *service.Registry { + registry := service.NewRegistry() + + resolved.RegisterService(registry) + ssmapi.RegisterService(registry) + + registerDERPService(registry) + ccm.RegisterService(registry) + ocm.RegisterService(registry) + + return registry +} diff --git a/core/register_naive.go b/core/register_naive.go new file mode 100644 index 0000000..7572552 --- /dev/null +++ b/core/register_naive.go @@ -0,0 +1,12 @@ +//go:build with_naive_outbound + +package core + +import ( + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/protocol/naive" +) + +func registerNaiveOutbound(registry *outbound.Registry) { + naive.RegisterOutbound(registry) +} diff --git a/core/register_naive_stub.go b/core/register_naive_stub.go new file mode 100644 index 0000000..50f3033 --- /dev/null +++ b/core/register_naive_stub.go @@ -0,0 +1,13 @@ +//go:build !with_naive_outbound + +package core + +import ( + "github.com/alireza0/s-ui/logger" + "github.com/sagernet/sing-box/adapter/outbound" +) + +func registerNaiveOutbound(registry *outbound.Registry) { + // naive outbound is disabled when built without with_naive_outbound tag + logger.Error("naive outbound is disabled when built without with_naive_outbound tag") +} diff --git a/core/register_tailscale.go b/core/register_tailscale.go new file mode 100644 index 0000000..b85bb47 --- /dev/null +++ b/core/register_tailscale.go @@ -0,0 +1,23 @@ +//go:build with_tailscale + +package core + +import ( + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/protocol/tailscale" + "github.com/sagernet/sing-box/service/derp" +) + +func registerTailscaleEndpoint(registry *endpoint.Registry) { + tailscale.RegisterEndpoint(registry) +} + +func registerTailscaleTransport(registry *dns.TransportRegistry) { + tailscale.RegistryTransport(registry) +} + +func registerDERPService(registry *service.Registry) { + derp.Register(registry) +} diff --git a/core/register_tailscale_stub.go b/core/register_tailscale_stub.go new file mode 100644 index 0000000..7a0b403 --- /dev/null +++ b/core/register_tailscale_stub.go @@ -0,0 +1,34 @@ +//go:build !with_tailscale + +package core + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerTailscaleEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.TailscaleEndpointOptions](registry, C.TypeTailscale, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailscaleEndpointOptions) (adapter.Endpoint, error) { + return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`) + }) +} + +func registerTailscaleTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.TailscaleDNSServerOptions](registry, C.DNSTypeTailscale, func(ctx context.Context, logger log.ContextLogger, tag string, options option.TailscaleDNSServerOptions) (adapter.DNSTransport, error) { + return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`) + }) +} + +func registerDERPService(registry *service.Registry) { + service.Register[option.DERPServiceOptions](registry, C.TypeDERP, func(ctx context.Context, logger log.ContextLogger, tag string, options option.DERPServiceOptions) (adapter.Service, error) { + return nil, E.New(`DERP is not included in this build, rebuild with -tags with_tailscale`) + }) +} diff --git a/core/tracker_conn.go b/core/tracker_conn.go new file mode 100644 index 0000000..626715f --- /dev/null +++ b/core/tracker_conn.go @@ -0,0 +1,219 @@ +package core + +import ( + "context" + "errors" + "io" + "net" + "sync" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/buf" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/network" +) + +type ConnectionInfo struct { + ID string + Conn net.Conn + PacketConn network.PacketConn + Inbound string + Type string // "tcp" or "udp" +} + +type ConnTracker struct { + access sync.Mutex + connections map[string]*ConnectionInfo +} + +func NewConnTracker() *ConnTracker { + return &ConnTracker{ + connections: make(map[string]*ConnectionInfo), + } +} + +func (c *ConnTracker) Reset() { + c.access.Lock() + defer c.access.Unlock() + for _, connInfo := range c.connections { + if connInfo.Conn != nil { + _ = connInfo.Conn.Close() + } + if connInfo.PacketConn != nil { + _ = connInfo.PacketConn.Close() + } + } + c.connections = make(map[string]*ConnectionInfo) +} + +func (c *ConnTracker) generateConnectionID() string { + return uuid.Must(uuid.NewV4()).String() +} + +func (c *ConnTracker) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { + connID := c.generateConnectionID() + connInfo := &ConnectionInfo{ + ID: connID, + Conn: conn, + Inbound: metadata.Inbound, + Type: "tcp", + } + + c.trackConnection(connID, connInfo) + + return c.createWrappedConn(conn, connID) +} + +func (c *ConnTracker) RoutedPacketConnection(ctx context.Context, conn network.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) network.PacketConn { + connID := c.generateConnectionID() + connInfo := &ConnectionInfo{ + ID: connID, + PacketConn: conn, + Inbound: metadata.Inbound, + Type: "udp", + } + + c.trackConnection(connID, connInfo) + + return c.createWrappedPacketConn(conn, connID) +} + +func (c *ConnTracker) CloseConnByInbound(inbound string) int { + c.access.Lock() + defer c.access.Unlock() + + closedCount := 0 + for connID, connInfo := range c.connections { + if connInfo.Inbound == inbound { + if connInfo.Conn != nil { + connInfo.Conn.Close() + } + if connInfo.PacketConn != nil { + connInfo.PacketConn.Close() + } + delete(c.connections, connID) + closedCount++ + } + } + return closedCount +} + +func (c *ConnTracker) trackConnection(connID string, connInfo *ConnectionInfo) { + c.access.Lock() + defer c.access.Unlock() + c.connections[connID] = connInfo +} + +func (c *ConnTracker) untrackConnection(connID string) { + c.access.Lock() + defer c.access.Unlock() + delete(c.connections, connID) +} + +// shouldUntrackIOErr reports whether err indicates the connection is done (peer closed, reset, etc.). +func shouldUntrackIOErr(err error) bool { + if err == nil { + return false + } + if errors.Is(err, io.EOF) { + return true + } + var ne net.Error + if errors.As(err, &ne) { + return !ne.Temporary() + } + return true +} + +func (c *ConnTracker) createWrappedConn(conn net.Conn, connID string) *wrappedConn { + return &wrappedConn{ + Conn: conn, + tracker: c, + connID: connID, + } +} + +func (c *ConnTracker) createWrappedPacketConn(conn network.PacketConn, connID string) *wrappedPacketConn { + return &wrappedPacketConn{ + PacketConn: conn, + tracker: c, + connID: connID, + } +} + +type wrappedConn struct { + net.Conn + tracker *ConnTracker + connID string + untrackOnce sync.Once +} + +func (w *wrappedConn) doUntrack() { + w.untrackOnce.Do(func() { + w.tracker.untrackConnection(w.connID) + }) +} + +func (w *wrappedConn) Read(b []byte) (int, error) { + n, err := w.Conn.Read(b) + if shouldUntrackIOErr(err) { + w.doUntrack() + } + return n, err +} + +func (w *wrappedConn) Write(b []byte) (int, error) { + n, err := w.Conn.Write(b) + if err != nil && shouldUntrackIOErr(err) { + w.doUntrack() + } + return n, err +} + +func (w *wrappedConn) Close() error { + w.doUntrack() + return w.Conn.Close() +} + +func (w *wrappedConn) Upstream() any { + return w.Conn +} + +type wrappedPacketConn struct { + network.PacketConn + tracker *ConnTracker + connID string + untrackOnce sync.Once +} + +func (w *wrappedPacketConn) doUntrack() { + w.untrackOnce.Do(func() { + w.tracker.untrackConnection(w.connID) + }) +} + +func (w *wrappedPacketConn) ReadPacket(buffer *buf.Buffer) (destination M.Socksaddr, err error) { + dest, err := w.PacketConn.ReadPacket(buffer) + if shouldUntrackIOErr(err) { + w.doUntrack() + } + return dest, err +} + +func (w *wrappedPacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + err := w.PacketConn.WritePacket(buffer, destination) + if err != nil && shouldUntrackIOErr(err) { + w.doUntrack() + } + return err +} + +func (w *wrappedPacketConn) Close() error { + w.doUntrack() + return w.PacketConn.Close() +} + +func (w *wrappedPacketConn) Upstream() any { + return w.PacketConn +} diff --git a/core/tracker_stats.go b/core/tracker_stats.go new file mode 100644 index 0000000..2592693 --- /dev/null +++ b/core/tracker_stats.go @@ -0,0 +1,153 @@ +package core + +import ( + "context" + "net" + "sync" + "time" + + "github.com/alireza0/s-ui/database/model" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/atomic" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/network" +) + +type Counter struct { + read *atomic.Int64 + write *atomic.Int64 +} + +type StatsTracker struct { + access sync.Mutex + inbounds map[string]Counter + outbounds map[string]Counter + users map[string]Counter +} + +func NewStatsTracker() *StatsTracker { + return &StatsTracker{ + inbounds: make(map[string]Counter), + outbounds: make(map[string]Counter), + users: make(map[string]Counter), + } +} + +func (c *StatsTracker) Reset() { + c.access.Lock() + defer c.access.Unlock() + c.inbounds = make(map[string]Counter) + c.outbounds = make(map[string]Counter) + c.users = make(map[string]Counter) +} + +func (c *StatsTracker) getReadCounters(inbound string, outbound string, user string) ([]*atomic.Int64, []*atomic.Int64) { + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + c.access.Lock() + defer c.access.Unlock() + + if inbound != "" { + readCounter = append(readCounter, c.loadOrCreateCounter(&c.inbounds, inbound).read) + writeCounter = append(writeCounter, c.inbounds[inbound].write) + } + if outbound != "" { + readCounter = append(readCounter, c.loadOrCreateCounter(&c.outbounds, outbound).read) + writeCounter = append(writeCounter, c.outbounds[outbound].write) + } + if user != "" { + readCounter = append(readCounter, c.loadOrCreateCounter(&c.users, user).read) + writeCounter = append(writeCounter, c.users[user].write) + } + return readCounter, writeCounter +} + +func (c *StatsTracker) loadOrCreateCounter(obj *map[string]Counter, name string) Counter { + counter, loaded := (*obj)[name] + if loaded { + return counter + } + counter = Counter{read: &atomic.Int64{}, write: &atomic.Int64{}} + (*obj)[name] = counter + return counter +} + +func (c *StatsTracker) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { + readCounter, writeCounter := c.getReadCounters(metadata.Inbound, matchOutbound.Tag(), metadata.User) + return bufio.NewInt64CounterConn(conn, readCounter, writeCounter) +} + +func (c *StatsTracker) RoutedPacketConnection(ctx context.Context, conn network.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) network.PacketConn { + readCounter, writeCounter := c.getReadCounters(metadata.Inbound, matchOutbound.Tag(), metadata.User) + return bufio.NewInt64CounterPacketConn(conn, readCounter, nil, writeCounter, nil) +} + +func (c *StatsTracker) GetStats() *[]model.Stats { + c.access.Lock() + defer c.access.Unlock() + + dt := time.Now().Unix() + + s := []model.Stats{} + for inbound, counter := range c.inbounds { + down := counter.write.Swap(0) + up := counter.read.Swap(0) + if down > 0 || up > 0 { + s = append(s, model.Stats{ + DateTime: dt, + Resource: "inbound", + Tag: inbound, + Direction: false, + Traffic: down, + }, model.Stats{ + DateTime: dt, + Resource: "inbound", + Tag: inbound, + Direction: true, + Traffic: up, + }) + } + } + + for outbound, counter := range c.outbounds { + down := counter.write.Swap(0) + up := counter.read.Swap(0) + if down > 0 || up > 0 { + s = append(s, model.Stats{ + DateTime: dt, + Resource: "outbound", + Tag: outbound, + Direction: false, + Traffic: down, + }, model.Stats{ + DateTime: dt, + Resource: "outbound", + Tag: outbound, + Direction: true, + Traffic: up, + }) + } + } + + for user, counter := range c.users { + down := counter.write.Swap(0) + up := counter.read.Swap(0) + if down > 0 || up > 0 { + s = append(s, model.Stats{ + DateTime: dt, + Resource: "user", + Tag: user, + Direction: false, + Traffic: down, + }, model.Stats{ + DateTime: dt, + Resource: "user", + Tag: user, + Direction: true, + Traffic: up, + }) + } + } + return &s +} diff --git a/cronjob/WALCheckpointJob.go b/cronjob/WALCheckpointJob.go new file mode 100644 index 0000000..f08b68c --- /dev/null +++ b/cronjob/WALCheckpointJob.go @@ -0,0 +1,19 @@ +package cronjob + +import ( + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/logger" +) + +type WALCheckpointJob struct{} + +func NewWALCheckpointJob() *WALCheckpointJob { + return &WALCheckpointJob{} +} + +func (s *WALCheckpointJob) Run() { + db := database.GetDB() + if err := db.Exec("PRAGMA wal_checkpoint(FULL)").Error; err != nil { + logger.Error("Error checkpointing WAL: ", err.Error()) + } +} diff --git a/cronjob/checkCoreJob.go b/cronjob/checkCoreJob.go new file mode 100644 index 0000000..85ce98f --- /dev/null +++ b/cronjob/checkCoreJob.go @@ -0,0 +1,17 @@ +package cronjob + +import ( + "github.com/alireza0/s-ui/service" +) + +type CheckCoreJob struct { + service.ConfigService +} + +func NewCheckCoreJob() *CheckCoreJob { + return &CheckCoreJob{} +} + +func (s *CheckCoreJob) Run() { + s.ConfigService.StartCore() +} diff --git a/cronjob/cronJob.go b/cronjob/cronJob.go new file mode 100644 index 0000000..6f1f24b --- /dev/null +++ b/cronjob/cronJob.go @@ -0,0 +1,43 @@ +package cronjob + +import ( + "time" + + "github.com/robfig/cron/v3" +) + +type CronJob struct { + cron *cron.Cron +} + +func NewCronJob() *CronJob { + return &CronJob{} +} + +func (c *CronJob) Start(loc *time.Location, trafficAge int) error { + c.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds()) + c.cron.Start() + + go func() { + // Start stats job + c.cron.AddJob("@every 10s", NewStatsJob(trafficAge > 0)) + // Start expiry job + c.cron.AddJob("@every 1m", NewDepleteJob()) + // Start deleting old stats + if trafficAge > 0 { + c.cron.AddJob("@daily", NewDelStatsJob(trafficAge)) + } + // Start core if it is not running + c.cron.AddJob("@every 5s", NewCheckCoreJob()) + // database WAL checkpoint + c.cron.AddJob("@every 10m", NewWALCheckpointJob()) + }() + + return nil +} + +func (c *CronJob) Stop() { + if c.cron != nil { + c.cron.Stop() + } +} diff --git a/cronjob/delStatsJob.go b/cronjob/delStatsJob.go new file mode 100644 index 0000000..8ebaae9 --- /dev/null +++ b/cronjob/delStatsJob.go @@ -0,0 +1,26 @@ +package cronjob + +import ( + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/service" +) + +type DelStatsJob struct { + service.StatsService + trafficAge int +} + +func NewDelStatsJob(ta int) *DelStatsJob { + return &DelStatsJob{ + trafficAge: ta, + } +} + +func (s *DelStatsJob) Run() { + err := s.StatsService.DelOldStats(s.trafficAge) + if err != nil { + logger.Warning("Deleting old statistics failed: ", err) + return + } + logger.Debug("Stats older than ", s.trafficAge, " days were deleted") +} diff --git a/cronjob/depleteJob.go b/cronjob/depleteJob.go new file mode 100644 index 0000000..adce0c8 --- /dev/null +++ b/cronjob/depleteJob.go @@ -0,0 +1,30 @@ +package cronjob + +import ( + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/service" +) + +type DepleteJob struct { + service.ClientService + service.InboundService +} + +func NewDepleteJob() *DepleteJob { + return new(DepleteJob) +} + +func (s *DepleteJob) Run() { + inboundIds, err := s.ClientService.DepleteClients() + if err != nil { + logger.Warning("Disable depleted users failed: ", err) + return + } + if len(inboundIds) > 0 { + err := s.InboundService.RestartInbounds(database.GetDB(), inboundIds) + if err != nil { + logger.Error("unable to restart inbounds: ", err) + } + } +} diff --git a/cronjob/statsJob.go b/cronjob/statsJob.go new file mode 100644 index 0000000..3749918 --- /dev/null +++ b/cronjob/statsJob.go @@ -0,0 +1,25 @@ +package cronjob + +import ( + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/service" +) + +type StatsJob struct { + service.StatsService + enableTraffic bool +} + +func NewStatsJob(saveTraffic bool) *StatsJob { + return &StatsJob{ + enableTraffic: saveTraffic, + } +} + +func (s *StatsJob) Run() { + err := s.StatsService.SaveStats(s.enableTraffic) + if err != nil { + logger.Warning("Get stats failed: ", err) + return + } +} diff --git a/database/backup.go b/database/backup.go new file mode 100644 index 0000000..61a6db6 --- /dev/null +++ b/database/backup.go @@ -0,0 +1,309 @@ +package database + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/alireza0/s-ui/cmd/migration" + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func GetDb(exclude string) ([]byte, error) { + exclude_changes, exclude_stats := false, false + for _, table := range strings.Split(exclude, ",") { + if table == "changes" { + exclude_changes = true + } else if table == "stats" { + exclude_stats = true + } + } + + dir, err := filepath.Abs(filepath.Dir(os.Args[0])) + if err != nil { + return nil, err + } + dbPath := dir + config.GetName() + "_" + time.Now().Format("20060102-200203") + ".db" + + backupDb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return nil, err + } + defer func() { + if sqlDB, e := backupDb.DB(); e == nil { + _ = sqlDB.Close() + } + }() + defer os.Remove(dbPath) + + err = backupDb.AutoMigrate( + &model.Setting{}, + &model.Tls{}, + &model.Inbound{}, + &model.Outbound{}, + &model.Endpoint{}, + &model.User{}, + &model.Stats{}, + &model.Client{}, + &model.Changes{}, + ) + if err != nil { + return nil, err + } + + var settings []model.Setting + var tls []model.Tls + var inbound []model.Inbound + var outbound []model.Outbound + var endpoint []model.Endpoint + var users []model.User + var clients []model.Client + var stats []model.Stats + var changes []model.Changes + + // Perform scans and handle errors + if err := db.Model(&model.Setting{}).Scan(&settings).Error; err != nil { + return nil, err + } else if len(settings) > 0 { + if err := backupDb.Save(settings).Error; err != nil { + return nil, err + } + } + if err := db.Model(&model.Tls{}).Scan(&tls).Error; err != nil { + return nil, err + } else if len(tls) > 0 { + if err := backupDb.Save(tls).Error; err != nil { + return nil, err + } + } + if err := db.Model(&model.Inbound{}).Scan(&inbound).Error; err != nil { + return nil, err + } else if len(inbound) > 0 { + if err := backupDb.Save(inbound).Error; err != nil { + return nil, err + } + } + if err := db.Model(&model.Outbound{}).Scan(&outbound).Error; err != nil { + return nil, err + } else if len(outbound) > 0 { + if err := backupDb.Save(outbound).Error; err != nil { + return nil, err + } + } + if err := db.Model(&model.Endpoint{}).Scan(&endpoint).Error; err != nil { + return nil, err + } else if len(endpoint) > 0 { + if err := backupDb.Save(endpoint).Error; err != nil { + return nil, err + } + } + if err := db.Model(&model.User{}).Scan(&users).Error; err != nil { + return nil, err + } else if len(users) > 0 { + if err := backupDb.Save(users).Error; err != nil { + return nil, err + } + } + if err := db.Model(&model.Client{}).Scan(&clients).Error; err != nil { + return nil, err + } else if len(clients) > 0 { + if err := backupDb.Save(clients).Error; err != nil { + return nil, err + } + } + + if !exclude_stats { + if err := db.Model(&model.Stats{}).Scan(&stats).Error; err != nil { + return nil, err + } + if len(stats) > 0 { + if err := backupDb.Save(stats).Error; err != nil { + return nil, err + } + } + } + if !exclude_changes { + if err := db.Model(&model.Changes{}).Scan(&changes).Error; err != nil { + return nil, err + } + if len(changes) > 0 { + if err := backupDb.Save(changes).Error; err != nil { + return nil, err + } + } + } + + // Update WAL + err = backupDb.Exec("PRAGMA wal_checkpoint;").Error + if err != nil { + return nil, err + } + + bdb, _ := backupDb.DB() + bdb.Close() + + // Open the file for reading + file, err := os.Open(dbPath) + if err != nil { + return nil, err + } + defer file.Close() + + // Read the file contents + fileContents, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + return fileContents, nil +} + +func ImportDB(file multipart.File) error { + // Check if the file is a SQLite database + isValidDb, err := IsSQLiteDB(file) + if err != nil { + return common.NewErrorf("Error checking db file format: %v", err) + } + if !isValidDb { + return common.NewError("Invalid db file format") + } + + // Reset the file reader to the beginning + _, err = file.Seek(0, 0) + if err != nil { + return common.NewErrorf("Error resetting file reader: %v", err) + } + + // Save the file as temporary file + tempPath := fmt.Sprintf("%s.temp", config.GetDBPath()) + // Remove the existing fallback file (if any) before creating one + _, err = os.Stat(tempPath) + if err == nil { + errRemove := os.Remove(tempPath) + if errRemove != nil { + return common.NewErrorf("Error removing existing temporary db file: %v", errRemove) + } + } + // Create the temporary file + tempFile, err := os.Create(tempPath) + if err != nil { + return common.NewErrorf("Error creating temporary db file: %v", err) + } + defer tempFile.Close() + + // Remove temp file before returning + defer os.Remove(tempPath) + + // Close old DB + old_db, _ := db.DB() + old_db.Close() + + // Save uploaded file to temporary file + _, err = io.Copy(tempFile, file) + if err != nil { + return common.NewErrorf("Error saving db: %v", err) + } + + // Check if we can init db or not + newDb, err := gorm.Open(sqlite.Open(tempPath), &gorm.Config{}) + if err != nil { + return common.NewErrorf("Error checking db: %v", err) + } + newDb_db, _ := newDb.DB() + if newDb_db != nil { + newDb_db.Close() + } + + // Backup the current database for fallback + fallbackPath := fmt.Sprintf("%s.backup", config.GetDBPath()) + // Remove the existing fallback file (if any) + _, err = os.Stat(fallbackPath) + if err == nil { + errRemove := os.Remove(fallbackPath) + if errRemove != nil { + return common.NewErrorf("Error removing existing fallback db file: %v", errRemove) + } + } + // Move the current database to the fallback location + err = os.Rename(config.GetDBPath(), fallbackPath) + if err != nil { + return common.NewErrorf("Error backing up temporary db file: %v", err) + } + + // Remove the temporary file before returning + defer os.Remove(fallbackPath) + + // Move temp to DB path + err = os.Rename(tempPath, config.GetDBPath()) + if err != nil { + errRename := os.Rename(fallbackPath, config.GetDBPath()) + if errRename != nil { + return common.NewErrorf("Error moving db file and restoring fallback: %v", errRename) + } + return common.NewErrorf("Error moving db file: %v", err) + } + + // Migrate DB + migration.MigrateDb() + err = InitDB(config.GetDBPath()) + if err != nil { + errRename := os.Rename(fallbackPath, config.GetDBPath()) + if errRename != nil { + return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename) + } + return common.NewErrorf("Error migrating db: %v", err) + } + + // Restart app + err = SendSighup() + if err != nil { + return common.NewErrorf("Error restarting app: %v", err) + } + + return nil +} + +func IsSQLiteDB(file io.Reader) (bool, error) { + signature := []byte("SQLite format 3\x00") + buf := make([]byte, len(signature)) + _, err := file.Read(buf) + if err != nil { + return false, err + } + return bytes.Equal(buf, signature), nil +} + +func SendSighup() error { + // Get the current process + process, err := os.FindProcess(os.Getpid()) + if err != nil { + return err + } + + // Send SIGHUP to the current process + go func() { + time.Sleep(3 * time.Second) + if runtime.GOOS == "windows" { + err = process.Kill() + } else { + err = process.Signal(syscall.SIGHUP) + } + if err != nil { + logger.Error("send signal SIGHUP failed:", err) + } + }() + return nil +} diff --git a/database/db.go b/database/db.go new file mode 100644 index 0000000..1af09ed --- /dev/null +++ b/database/db.go @@ -0,0 +1,127 @@ +package database + +import ( + "encoding/json" + "os" + "path" + "strings" + "time" + + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/database/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var db *gorm.DB + +func initUser() error { + var count int64 + err := db.Model(&model.User{}).Count(&count).Error + if err != nil { + return err + } + if count == 0 { + user := &model.User{ + Username: "admin", + Password: "admin", + } + return db.Create(user).Error + } + return nil +} + +func OpenDB(dbPath string) error { + dir := path.Dir(dbPath) + err := os.MkdirAll(dir, 01740) + if err != nil { + return err + } + + var gormLogger logger.Interface + + if config.IsDebug() { + gormLogger = logger.Default + } else { + gormLogger = logger.Discard + } + + c := &gorm.Config{ + Logger: gormLogger, + } + sep := "?" + if strings.Contains(dbPath, "?") { + sep = "&" + } + // _cache_size=-200 caps each connection's page cache at ~200 KiB + // (default is ~2 MiB), reducing memory amplification if a connection + // escapes the pool. + dsn := dbPath + sep + "_busy_timeout=10000&_journal_mode=WAL&_cache_size=-200" + db, err = gorm.Open(sqlite.Open(dsn), c) + if err != nil { + return err + } + + sqlDB, err := db.DB() + if err != nil { + return err + } + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(2) + sqlDB.SetConnMaxLifetime(time.Hour) + sqlDB.SetConnMaxIdleTime(5 * time.Minute) + + if config.IsDebug() { + db = db.Debug() + } + return nil +} + +func InitDB(dbPath string) error { + err := OpenDB(dbPath) + if err != nil { + return err + } + + // Default Outbounds + if !db.Migrator().HasTable(&model.Outbound{}) { + db.Migrator().CreateTable(&model.Outbound{}) + defaultOutbound := []model.Outbound{ + {Type: "direct", Tag: "direct", Options: json.RawMessage(`{}`)}, + } + db.Create(&defaultOutbound) + } + + err = db.AutoMigrate( + &model.Setting{}, + &model.Tls{}, + &model.Inbound{}, + &model.Outbound{}, + &model.Service{}, + &model.Endpoint{}, + &model.User{}, + &model.Tokens{}, + &model.Stats{}, + &model.Client{}, + &model.Changes{}, + ) + if err != nil { + return err + } + err = initUser() + if err != nil { + return err + } + + return nil +} + +func GetDB() *gorm.DB { + return db +} + +func IsNotFound(err error) bool { + return err == gorm.ErrRecordNotFound +} diff --git a/database/model/endpoints.go b/database/model/endpoints.go new file mode 100644 index 0000000..960c963 --- /dev/null +++ b/database/model/endpoints.go @@ -0,0 +1,63 @@ +package model + +import ( + "encoding/json" +) + +type Endpoint struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Type string `json:"type" form:"type"` + Tag string `json:"tag" form:"tag" gorm:"unique"` + Options json.RawMessage `json:"-" form:"-"` + Ext json.RawMessage `json:"ext" form:"ext"` +} + +func (o *Endpoint) UnmarshalJSON(data []byte) error { + var err error + var raw map[string]interface{} + if err = json.Unmarshal(data, &raw); err != nil { + return err + } + + // Extract fixed fields and store the rest in Options + if val, exists := raw["id"].(float64); exists { + o.Id = uint(val) + } + delete(raw, "id") + o.Type, _ = raw["type"].(string) + delete(raw, "type") + o.Tag = raw["tag"].(string) + delete(raw, "tag") + o.Ext, _ = json.MarshalIndent(raw["ext"], "", " ") + delete(raw, "ext") + + // Remaining fields + o.Options, err = json.MarshalIndent(raw, "", " ") + return err +} + +// MarshalJSON customizes marshalling +func (o Endpoint) MarshalJSON() ([]byte, error) { + // Combine fixed fields and dynamic fields into one map + combined := make(map[string]interface{}) + switch o.Type { + case "warp": + combined["type"] = "wireguard" + default: + combined["type"] = o.Type + } + combined["tag"] = o.Tag + + if o.Options != nil { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(o.Options, &restFields); err != nil { + return nil, err + } + + for k, v := range restFields { + combined[k] = v + } + } + + return json.Marshal(combined) +} diff --git a/database/model/inbounds.go b/database/model/inbounds.go new file mode 100644 index 0000000..72e09ec --- /dev/null +++ b/database/model/inbounds.go @@ -0,0 +1,103 @@ +package model + +import ( + "encoding/json" +) + +type Inbound struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Type string `json:"type" form:"type"` + Tag string `json:"tag" form:"tag" gorm:"unique"` + + // Foreign key to tls table + TlsId uint `json:"tls_id" form:"tls_id"` + Tls *Tls `json:"tls" form:"tls" gorm:"foreignKey:TlsId;references:Id"` + + Addrs json.RawMessage `json:"addrs" form:"addrs"` + OutJson json.RawMessage `json:"out_json" form:"out_json"` + Options json.RawMessage `json:"-" form:"-"` +} + +func (i *Inbound) UnmarshalJSON(data []byte) error { + var err error + var raw map[string]interface{} + if err = json.Unmarshal(data, &raw); err != nil { + return err + } + + // Extract fixed fields and store the rest in Options + if val, exists := raw["id"].(float64); exists { + i.Id = uint(val) + } + delete(raw, "id") + i.Type, _ = raw["type"].(string) + delete(raw, "type") + i.Tag, _ = raw["tag"].(string) + delete(raw, "tag") + + // TlsId + if val, exists := raw["tls_id"].(float64); exists { + i.TlsId = uint(val) + } + delete(raw, "tls_id") + delete(raw, "tls") + delete(raw, "users") + + // Addrs + i.Addrs, _ = json.MarshalIndent(raw["addrs"], "", " ") + delete(raw, "addrs") + + // OutJson + i.OutJson, _ = json.MarshalIndent(raw["out_json"], "", " ") + delete(raw, "out_json") + + // Remaining fields + i.Options, err = json.MarshalIndent(raw, "", " ") + return err +} + +// MarshalJSON customizes marshalling +func (i Inbound) MarshalJSON() ([]byte, error) { + // Combine fixed fields and dynamic fields into one map + combined := make(map[string]interface{}) + combined["type"] = i.Type + combined["tag"] = i.Tag + if i.Tls != nil { + combined["tls"] = i.Tls.Server + } + + if i.Options != nil { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(i.Options, &restFields); err != nil { + return nil, err + } + + for k, v := range restFields { + combined[k] = v + } + } + + return json.Marshal(combined) +} + +func (i Inbound) MarshalFull() (*map[string]interface{}, error) { + combined := make(map[string]interface{}) + combined["id"] = i.Id + combined["type"] = i.Type + combined["tag"] = i.Tag + combined["tls_id"] = i.TlsId + combined["addrs"] = i.Addrs + combined["out_json"] = i.OutJson + + if i.Options != nil { + var restFields map[string]interface{} + if err := json.Unmarshal(i.Options, &restFields); err != nil { + return nil, err + } + + for k, v := range restFields { + combined[k] = v + } + } + return &combined, nil +} diff --git a/database/model/model.go b/database/model/model.go new file mode 100644 index 0000000..396a1bd --- /dev/null +++ b/database/model/model.go @@ -0,0 +1,73 @@ +package model + +import "encoding/json" + +type Setting struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Key string `json:"key" form:"key"` + Value string `json:"value" form:"value"` +} + +type Tls struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" form:"name"` + Server json.RawMessage `json:"server" form:"server"` + Client json.RawMessage `json:"client" form:"client"` +} + +type User struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Username string `json:"username" form:"username"` + Password string `json:"password" form:"password"` + LastLogins string `json:"lastLogin"` +} + +type Client struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Enable bool `json:"enable" form:"enable"` + Name string `json:"name" form:"name"` + Config json.RawMessage `json:"config,omitempty" form:"config"` + Inbounds json.RawMessage `json:"inbounds" form:"inbounds"` + Links json.RawMessage `json:"links,omitempty" form:"links"` + Volume int64 `json:"volume" form:"volume"` + Expiry int64 `json:"expiry" form:"expiry"` + Down int64 `json:"down" form:"down"` + Up int64 `json:"up" form:"up"` + Desc string `json:"desc" form:"desc"` + Group string `json:"group" form:"group"` + + // Delay start and periodic reset + DelayStart bool `json:"delayStart" form:"delayStart" gorm:"default:false;not null"` + AutoReset bool `json:"autoReset" form:"autoReset" gorm:"default:false;not null"` + ResetDays int `json:"resetDays" form:"resetDays" gorm:"default:0;not null"` + NextReset int64 `json:"nextReset" form:"nextReset" gorm:"default:0;not null"` + TotalUp int64 `json:"totalUp" form:"totalUp" gorm:"default:0;not null"` + TotalDown int64 `json:"totalDown" form:"totalDown" gorm:"default:0;not null"` +} + +type Stats struct { + Id uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + DateTime int64 `json:"dateTime"` + Resource string `json:"resource"` + Tag string `json:"tag"` + Direction bool `json:"direction"` + Traffic int64 `json:"traffic"` +} + +type Changes struct { + Id uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + DateTime int64 `json:"dateTime"` + Actor string `json:"actor"` + Key string `json:"key"` + Action string `json:"action"` + Obj json.RawMessage `json:"obj"` +} + +type Tokens struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Desc string `json:"desc" form:"desc"` + Token string `json:"token" form:"token"` + Expiry int64 `json:"expiry" form:"expiry"` + UserId uint `json:"userId" form:"userId"` + User *User `json:"user" gorm:"foreignKey:UserId;references:Id"` +} diff --git a/database/model/outbounds.go b/database/model/outbounds.go new file mode 100644 index 0000000..9aec2ae --- /dev/null +++ b/database/model/outbounds.go @@ -0,0 +1,53 @@ +package model + +import "encoding/json" + +type Outbound struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Type string `json:"type" form:"type"` + Tag string `json:"tag" form:"tag" gorm:"unique"` + Options json.RawMessage `json:"-" form:"-"` +} + +func (o *Outbound) UnmarshalJSON(data []byte) error { + var err error + var raw map[string]interface{} + if err = json.Unmarshal(data, &raw); err != nil { + return err + } + + // Extract fixed fields and store the rest in Options + if val, exists := raw["id"].(float64); exists { + o.Id = uint(val) + } + delete(raw, "id") + o.Type, _ = raw["type"].(string) + delete(raw, "type") + o.Tag = raw["tag"].(string) + delete(raw, "tag") + + // Remaining fields + o.Options, err = json.MarshalIndent(raw, "", " ") + return err +} + +// MarshalJSON customizes marshalling +func (o Outbound) MarshalJSON() ([]byte, error) { + // Combine fixed fields and dynamic fields into one map + combined := make(map[string]interface{}) + combined["type"] = o.Type + combined["tag"] = o.Tag + + if o.Options != nil { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(o.Options, &restFields); err != nil { + return nil, err + } + + for k, v := range restFields { + combined[k] = v + } + } + + return json.Marshal(combined) +} diff --git a/database/model/services.go b/database/model/services.go new file mode 100644 index 0000000..cf7acc9 --- /dev/null +++ b/database/model/services.go @@ -0,0 +1,90 @@ +package model + +import ( + "encoding/json" +) + +type Service struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Type string `json:"type" form:"type"` + Tag string `json:"tag" form:"tag" gorm:"unique"` + + // Foreign key to tls table + TlsId uint `json:"tls_id" form:"tls_id"` + Tls *Tls `json:"tls" form:"tls" gorm:"foreignKey:TlsId;references:Id"` + + Options json.RawMessage `json:"-" form:"-"` +} + +func (i *Service) UnmarshalJSON(data []byte) error { + var err error + var raw map[string]interface{} + if err = json.Unmarshal(data, &raw); err != nil { + return err + } + + // Extract fixed fields and store the rest in Options + if val, exists := raw["id"].(float64); exists { + i.Id = uint(val) + } + delete(raw, "id") + i.Type, _ = raw["type"].(string) + delete(raw, "type") + i.Tag, _ = raw["tag"].(string) + delete(raw, "tag") + + // TlsId + if val, exists := raw["tls_id"].(float64); exists { + i.TlsId = uint(val) + } + delete(raw, "tls_id") + delete(raw, "tls") + + // Remaining fields + i.Options, err = json.MarshalIndent(raw, "", " ") + return err +} + +// MarshalJSON customizes marshalling +func (i Service) MarshalJSON() ([]byte, error) { + // Combine fixed fields and dynamic fields into one map + combined := make(map[string]interface{}) + combined["type"] = i.Type + combined["tag"] = i.Tag + if i.Tls != nil { + combined["tls"] = i.Tls.Server + } + + if i.Options != nil { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(i.Options, &restFields); err != nil { + return nil, err + } + + for k, v := range restFields { + combined[k] = v + } + } + + return json.Marshal(combined) +} + +func (i Service) MarshalFull() (*map[string]interface{}, error) { + combined := make(map[string]interface{}) + combined["id"] = i.Id + combined["type"] = i.Type + combined["tag"] = i.Tag + combined["tls_id"] = i.TlsId + + if i.Options != nil { + var restFields map[string]interface{} + if err := json.Unmarshal(i.Options, &restFields); err != nil { + return nil, err + } + + for k, v := range restFields { + combined[k] = v + } + } + return &combined, nil +} diff --git a/docker-build-test.sh b/docker-build-test.sh new file mode 100755 index 0000000..f2df316 --- /dev/null +++ b/docker-build-test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Test Docker multi-platform build (linux/amd64, 386, arm64, arm/v7, arm/v6) +# Requires: frontend_dist/ (run from repo root after building frontend) + +set -e +cd "$(dirname "$0")/.." + +echo "==> Preparing frontend_dist..." +if [ ! -d "frontend_dist" ] || [ -z "$(ls -A frontend_dist 2>/dev/null)" ]; then + echo "Building frontend..." + (cd frontend && npm install --prefer-offline --no-audit && npm run build) + rm -rf frontend_dist + mkdir -p frontend_dist + cp -R frontend/dist/* frontend_dist/ + echo "frontend_dist ready." +else + echo "frontend_dist exists, skipping frontend build." +fi + +PLATFORMS="linux/amd64,linux/386,linux/arm64/v8,linux/arm/v7,linux/arm/v6" +echo "==> Testing Docker build for: $PLATFORMS" +docker buildx build \ + --platform "$PLATFORMS" \ + -f Dockerfile.frontend-artifact \ + --build-arg CRONET_RELEASE=latest \ + --progress=plain \ + . 2>&1 | tee docker-build-test.log + +echo "==> Done. Check docker-build-test.log for full output." diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f78f5a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +--- +services: + s-ui: + image: alireza7/s-ui + container_name: s-ui + hostname: "s-ui" + volumes: + - "./db:/app/db" + - "./cert:/app/cert" + tty: true + restart: unless-stopped + ports: + - "2095:2095" + - "2096:2096" + networks: + - s-ui + entrypoint: "./entrypoint.sh" + +networks: + s-ui: + driver: bridge + \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..1c4a4e8 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +DB_PATH="${SUI_DB_FOLDER:-/app/db}/s-ui.db" +if [ -f "$DB_PATH" ]; then + ./sui migrate +fi + +exec ./sui \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8e1ad2a --- /dev/null +++ b/go.mod @@ -0,0 +1,199 @@ +module github.com/alireza0/s-ui + +go 1.26.3 + +require ( + github.com/gin-contrib/gzip v1.2.6 + github.com/gin-contrib/sessions v1.1.0 + github.com/gin-gonic/gin v1.12.0 + github.com/gofrs/uuid/v5 v5.4.0 + github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 + github.com/robfig/cron/v3 v3.0.1 + github.com/sagernet/sing v0.8.10 + github.com/sagernet/sing-box v1.13.12 + github.com/shirou/gopsutil/v4 v4.26.4 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/ajg/form v1.5.1 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect + github.com/anytls/sing-anytls v0.0.11 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/caddyserver/certmagic v0.25.2 // indirect + github.com/caddyserver/zerossl v0.1.5 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect + github.com/cretz/bine v0.2.0 // indirect + github.com/database64128/netx-go v0.1.1 // indirect + github.com/database64128/tfo-go/v2 v2.3.2 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gaissmai/bart v0.18.0 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-chi/render v1.0.3 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/keybase/go-keychain v0.0.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/libdns/acmedns v0.5.0 // indirect + github.com/libdns/alidns v1.0.6 // indirect + github.com/libdns/cloudflare v0.2.2 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.30 // indirect + github.com/mdlayher/netlink v1.9.0 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/metacubex/utls v1.8.4 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/miekg/dns v1.1.72 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/openai/openai-go/v3 v3.26.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect + github.com/sagernet/cors v1.2.1 // indirect + github.com/sagernet/cronet-go v0.0.0-20260513071958-2faf34666c2c // indirect + github.com/sagernet/cronet-go/all v0.0.0-20260513071958-2faf34666c2c // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/fswatch v0.1.2 // indirect + github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 // indirect + github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect + github.com/sagernet/nftables v0.3.0-mod.2 // indirect + github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 // indirect + github.com/sagernet/sing-mux v0.3.4 // indirect + github.com/sagernet/sing-quic v0.6.1 // indirect + github.com/sagernet/sing-shadowsocks v0.2.8 // indirect + github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect + github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect + github.com/sagernet/sing-tun v0.8.9 // indirect + github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect + github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 // indirect + github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c // indirect + github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.42.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + lukechampine.com/blake3 v1.4.1 // indirect +) + +replace github.com/quic-go/quic-go => github.com/quic-go/quic-go v0.57.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..63e9c7c --- /dev/null +++ b/go.sum @@ -0,0 +1,485 @@ +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= +github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= +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.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= +github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= +github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= +github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= +github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= +github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw= +github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= +github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +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/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= +github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= +github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg= +github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU= +github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo= +github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +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/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +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.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= +github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY= +github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +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/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= +github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ= +github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM= +github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +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/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY= +github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= +github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= +github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= +github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= +github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= +github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= +github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= +github.com/sagernet/cronet-go v0.0.0-20260513071958-2faf34666c2c h1:JatMWK/reVa5Y+x3D3l49SVtHB/EQUEtQnAFTxPBNxY= +github.com/sagernet/cronet-go v0.0.0-20260513071958-2faf34666c2c/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= +github.com/sagernet/cronet-go/all v0.0.0-20260513071958-2faf34666c2c h1:F/tL+VzLZ2F4SNZZze6SRSRL/jcX7LwIsuL1+hECiz0= +github.com/sagernet/cronet-go/all v0.0.0-20260513071958-2faf34666c2c/go.mod h1:GGE1tBbFgHq8kV99AKX1JXFY+9FvgNSK/W6Z5j24Ihc= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260513071149-ade33496efb8 h1:NCKxyAnEkwsEueAEbuuUUjs2FEZAIflr+WN3Mwbvsdg= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260513071149-ade33496efb8/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260513071149-ade33496efb8 h1:o3AGm7/L/zAdBvPu0u1dFgDR/tH086qyuXZkjLNJ7/E= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260513071149-ade33496efb8 h1:AeO8yHQj7aNj16fiJNU797alyuM3T+3VASnETHeV220= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260513071149-ade33496efb8/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260513071149-ade33496efb8 h1:ZgW2/Qq/5Q6eTlW80QXLokU56kfjvbLJSEGYTkcG3hU= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260513071149-ade33496efb8 h1:orYgvX5X9aUa+sRrAuuqA6PXiiBUI2D367ZJqan4lIU= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260513071149-ade33496efb8 h1:2w1s3wEk7qW2w4IGwlJflxwXBM97UChNiqAErKpvHr0= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260513071149-ade33496efb8 h1:22k6CB3d4gHT+SARUh2bgNyGU4QwYupcCdP8cGuwygY= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260513071149-ade33496efb8/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260513071149-ade33496efb8 h1:PkJ5EaqLrv6bNR+MHx1/joJXoRcoYcV7JA4NtXbFQsc= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260513071149-ade33496efb8 h1:V629H+OQ9yOR2d0Jkq5y42j5btpvoSWJbUaBH7FCGPI= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260513071149-ade33496efb8/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260513071149-ade33496efb8 h1:gfObF5uoqJslCdMRRm2Yo+gmPJQPVlrci5Myrki0Kzk= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260513071149-ade33496efb8/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260513071149-ade33496efb8 h1:JRPN0RBKvoOBEHezJh/54KD9ftWL7YadtcCgOf/vRnw= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260513071149-ade33496efb8 h1:mM8gNdFlXSpjZFs9kgaMgW94oTRF8YdEEQgdOp/OEUA= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260513071149-ade33496efb8 h1:ZtCH0fH07giTK6wqkenA9fdFYt7krjWiyOvC8z9nPwk= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260513071149-ade33496efb8 h1:Uviqmw+Q4No9kCxJWJ5CYcq6PNHB9f0jQhd15j39+no= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260513071149-ade33496efb8/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260513071149-ade33496efb8 h1:la4zRTE9zpZCmsixwzKT2LnHuo0e439EmGwOlB1An9Q= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260513071149-ade33496efb8 h1:KodFGMqn+X2dqET0O3xww3iemAGmpoC8U4JW8gwt0x4= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260513071149-ade33496efb8 h1:QTk1RXNLOIcorZYcF0rBrwLpCIZCKEA2Jr69eFrt8xg= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260513071149-ade33496efb8 h1:SXqSlM/GjZFvNdUV3IvHq5gqHfW4iWlQHMGzEsgXGXE= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260513071149-ade33496efb8 h1:aAgLWpfESvy7rfDVH7ioOZQ7u2kmRsbUqJVrwJtkFWs= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260513071149-ade33496efb8 h1:oTLUyhLckc8TZQ8SRCapgTYyRbz1pBpIvzjMCLMPFu8= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260513071149-ade33496efb8/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260513071149-ade33496efb8 h1:LHm/85Y3zN0kNgG+li5qHvP3dzvavEytCYzdLtrfrrg= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260513071149-ade33496efb8/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260513071149-ade33496efb8 h1:Pom5TSHV8Cln73uOgQlJ+JtmEu9xh+OuLHWq57dBaVg= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260513071149-ade33496efb8 h1:1pPcb15BonaFl4153tRo7zOJ7U2zD1vjH+5JipSfJ3g= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260513071149-ade33496efb8 h1:3Dy4exYQ/IVJGcnTtvW3LmjfjDaxFgJT1hn/ALBpd2M= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260513071149-ade33496efb8 h1:mo9YMCYTGCRUiWNKtPVQb+qEetufxnch372xUOh9q3M= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260513071149-ade33496efb8/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260513071149-ade33496efb8 h1:mhh3JEDDx68oKT4kfqKlWp5QTyzVR84OS/qgqHYIbq0= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260513071149-ade33496efb8 h1:04KOo38hZojV3bJ5Vqwbpj48ZQy6o7aliYXLN/TNX6g= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260513071149-ade33496efb8/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260513071149-ade33496efb8 h1:p535QakpDZEeBz/BfFZGZo0D+Pdn74TE8UTr6c6MSog= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260513071149-ade33496efb8 h1:dovTyKHh3toBIUOS70P4Yx+3Baw6Gppsfy1sJbXoAy0= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= +github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= +github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/GejpNh+z30d5H1hwaB0nD9eY3o= +github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4= +github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= +github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= +github.com/sagernet/nftables v0.3.0-mod.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje+vW5Q0OQ= +github.com/sagernet/nftables v0.3.0-mod.2/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= +github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU= +github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing-box v1.13.12 h1:U7znhM2VRq76ZdN4X5eRKdaUxHJNHMGKpo2csKqGTsg= +github.com/sagernet/sing-box v1.13.12/go.mod h1:dgvcaNmNZTdeiDbnpQ+EghpE+KPR+8yKtfoVEhUi3jw= +github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= +github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-quic v0.6.1 h1:lx0tcm99wIA1RkyvILNzRSsMy1k7TTQYIhx71E/WBlw= +github.com/sagernet/sing-quic v0.6.1/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= +github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= +github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= +github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= +github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= +github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= +github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-tun v0.8.9 h1:ixFKKUGdVcJl4wb0xbL36hobiw9l6DIH497EQf5ILpM= +github.com/sagernet/sing-tun v0.8.9/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= +github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= +github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 h1:8zc1Aph1+ElqF9/7aSPkO0o4vTd+AfQC+CO324mLWGg= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc= +github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c h1:f9cXNB+IOOPnR8DOLMTpr42jf7naxh5Un5Y09BBf5Cg= +github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= +github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +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= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..501e941 --- /dev/null +++ b/install.sh @@ -0,0 +1,188 @@ +#!/bin/bash + +red='\033[0;31m' +green='\033[0;32m' +yellow='\033[0;33m' +plain='\033[0m' + +cur_dir=$(pwd) + +# check root +[[ $EUID -ne 0 ]] && echo -e "${red}Fatal error: ${plain} Please run this script with root privilege \n " && exit 1 + +# Check OS and set release variable +if [[ -f /etc/os-release ]]; then + source /etc/os-release + release=$ID +elif [[ -f /usr/lib/os-release ]]; then + source /usr/lib/os-release + release=$ID +else + echo "Failed to check the system OS, please contact the author!" >&2 + exit 1 +fi +echo "The OS release is: $release" + +arch() { + case "$(uname -m)" in + x86_64 | x64 | amd64) echo 'amd64' ;; + i*86 | x86) echo '386' ;; + armv8* | armv8 | arm64 | aarch64) echo 'arm64' ;; + armv7* | armv7 | arm) echo 'armv7' ;; + armv6* | armv6) echo 'armv6' ;; + armv5* | armv5) echo 'armv5' ;; + s390x) echo 's390x' ;; + *) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;; + esac +} + +echo "arch: $(arch)" + +install_base() { + case "${release}" in + centos | almalinux | rocky | oracle) + yum -y update && yum install -y -q wget curl tar tzdata + ;; + fedora) + dnf -y update && dnf install -y -q wget curl tar tzdata + ;; + arch | manjaro | parch) + pacman -Syu && pacman -Syu --noconfirm wget curl tar tzdata + ;; + opensuse-tumbleweed) + zypper refresh && zypper -q install -y wget curl tar timezone + ;; + *) + apt-get update && apt-get install -y -q wget curl tar tzdata + ;; + esac +} + +config_after_install() { + echo -e "${yellow}Migration... ${plain}" + /usr/local/s-ui/sui migrate + + echo -e "${yellow}Install/update finished! For security it's recommended to modify panel settings ${plain}" + read -p "Do you want to continue with the modification [y/n]? ": config_confirm + if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then + echo -e "Enter the ${yellow}panel port${plain} (leave blank for existing/default value):" + read config_port + echo -e "Enter the ${yellow}panel path${plain} (leave blank for existing/default value):" + read config_path + + # Sub configuration + echo -e "Enter the ${yellow}subscription port${plain} (leave blank for existing/default value):" + read config_subPort + echo -e "Enter the ${yellow}subscription path${plain} (leave blank for existing/default value):" + read config_subPath + + # Set configs + echo -e "${yellow}Initializing, please wait...${plain}" + params="" + [ -z "$config_port" ] || params="$params -port $config_port" + [ -z "$config_path" ] || params="$params -path $config_path" + [ -z "$config_subPort" ] || params="$params -subPort $config_subPort" + [ -z "$config_subPath" ] || params="$params -subPath $config_subPath" + /usr/local/s-ui/sui setting ${params} + + read -p "Do you want to change admin credentials [y/n]? ": admin_confirm + if [[ "${admin_confirm}" == "y" || "${admin_confirm}" == "Y" ]]; then + # First admin credentials + read -p "Please set up your username:" config_account + read -p "Please set up your password:" config_password + + # Set credentials + echo -e "${yellow}Initializing, please wait...${plain}" + /usr/local/s-ui/sui admin -username ${config_account} -password ${config_password} + else + echo -e "${yellow}Your current admin credentials: ${plain}" + /usr/local/s-ui/sui admin -show + fi + else + echo -e "${red}cancel...${plain}" + if [[ ! -f "/usr/local/s-ui/db/s-ui.db" ]]; then + local usernameTemp=$(head -c 6 /dev/urandom | base64) + local passwordTemp=$(head -c 6 /dev/urandom | base64) + echo -e "this is a fresh installation,will generate random login info for security concerns:" + echo -e "###############################################" + echo -e "${green}username:${usernameTemp}${plain}" + echo -e "${green}password:${passwordTemp}${plain}" + echo -e "###############################################" + echo -e "${red}if you forgot your login info,you can type ${green}s-ui${red} for configuration menu${plain}" + /usr/local/s-ui/sui admin -username ${usernameTemp} -password ${passwordTemp} + else + echo -e "${red} this is your upgrade,will keep old settings,if you forgot your login info,you can type ${green}s-ui${red} for configuration menu${plain}" + fi + fi +} + +prepare_services() { + if [[ -f "/etc/systemd/system/sing-box.service" ]]; then + echo -e "${yellow}Stopping sing-box service... ${plain}" + systemctl stop sing-box + rm -f /usr/local/s-ui/bin/sing-box /usr/local/s-ui/bin/runSingbox.sh /usr/local/s-ui/bin/signal + fi + if [[ -e "/usr/local/s-ui/bin" ]]; then + echo -e "###############################################################" + echo -e "${green}/usr/local/s-ui/bin${red} directory exists yet!" + echo -e "Please check the content and delete it manually after migration ${plain}" + echo -e "###############################################################" + fi + systemctl daemon-reload +} + +install_s-ui() { + cd /tmp/ + + if [ $# == 0 ]; then + last_version=$(curl -Ls "https://api.github.com/repos/alireza0/s-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [[ ! -n "$last_version" ]]; then + echo -e "${red}Failed to fetch s-ui version, it maybe due to Github API restrictions, please try it later${plain}" + exit 1 + fi + echo -e "Got s-ui latest version: ${last_version}, beginning the installation..." + wget -N --no-check-certificate -O /tmp/s-ui-linux-$(arch).tar.gz https://github.com/alireza0/s-ui/releases/download/${last_version}/s-ui-linux-$(arch).tar.gz + if [[ $? -ne 0 ]]; then + echo -e "${red}Downloading s-ui failed, please be sure that your server can access Github ${plain}" + exit 1 + fi + else + last_version=$1 + url="https://github.com/alireza0/s-ui/releases/download/${last_version}/s-ui-linux-$(arch).tar.gz" + echo -e "Beginning the install s-ui v$1" + wget -N --no-check-certificate -O /tmp/s-ui-linux-$(arch).tar.gz ${url} + if [[ $? -ne 0 ]]; then + echo -e "${red}download s-ui v$1 failed,please check the version exists${plain}" + exit 1 + fi + fi + + if [[ -e /usr/local/s-ui/ ]]; then + systemctl stop s-ui + fi + + tar zxvf s-ui-linux-$(arch).tar.gz + rm s-ui-linux-$(arch).tar.gz -f + + chmod +x s-ui/sui s-ui/s-ui.sh + cp s-ui/s-ui.sh /usr/bin/s-ui + cp -rf s-ui /usr/local/ + cp -f s-ui/*.service /etc/systemd/system/ + rm -rf s-ui + + config_after_install + prepare_services + + systemctl enable s-ui --now + + echo -e "${green}s-ui v${last_version}${plain} installation finished, it is up and running now..." + echo -e "You may access the Panel with following URL(s):${green}" + /usr/local/s-ui/sui uri + echo -e "${plain}" + echo -e "" + s-ui help +} + +echo -e "${green}Executing...${plain}" +install_base +install_s-ui $1 diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..2dced5c --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,128 @@ +package logger + +import ( + "fmt" + "os" + "time" + + "github.com/op/go-logging" +) + +var ( + logger *logging.Logger + logBuffer []struct { + time string + level logging.Level + log string + } +) + +func InitLogger(level logging.Level) { + newLogger := logging.MustGetLogger("s-ui") + var err error + var backend logging.Backend + var format logging.Formatter + + _, inContainer := os.LookupEnv("container") + if !inContainer { + if _, statErr := os.Stat("/.dockerenv"); statErr == nil { + inContainer = true + } + } + if inContainer { + backend = logging.NewLogBackend(os.Stderr, "", 0) + format = logging.MustStringFormatter(`%{time:2006/01/02 15:04:05} %{level} - %{message}`) + } else { + backend, err = logging.NewSyslogBackend("") + if err != nil { + fmt.Println("Unable to use syslog: " + err.Error()) + backend = logging.NewLogBackend(os.Stderr, "", 0) + } + if err != nil { + format = logging.MustStringFormatter(`%{time:2006/01/02 15:04:05} %{level} - %{message}`) + } else { + format = logging.MustStringFormatter(`%{level} - %{message}`) + } + } + + backendFormatter := logging.NewBackendFormatter(backend, format) + backendLeveled := logging.AddModuleLevel(backendFormatter) + backendLeveled.SetLevel(level, "s-ui") + newLogger.SetBackend(backendLeveled) + + logger = newLogger +} + +func GetLogger() *logging.Logger { + return logger +} + +func Debug(args ...interface{}) { + logger.Debug(args...) + addToBuffer("DEBUG", fmt.Sprint(args...)) +} + +func Debugf(format string, args ...interface{}) { + logger.Debugf(format, args...) + addToBuffer("DEBUG", fmt.Sprintf(format, args...)) +} + +func Info(args ...interface{}) { + logger.Info(args...) + addToBuffer("INFO", fmt.Sprint(args...)) +} + +func Infof(format string, args ...interface{}) { + logger.Infof(format, args...) + addToBuffer("INFO", fmt.Sprintf(format, args...)) +} + +func Warning(args ...interface{}) { + logger.Warning(args...) + addToBuffer("WARNING", fmt.Sprint(args...)) +} + +func Warningf(format string, args ...interface{}) { + logger.Warningf(format, args...) + addToBuffer("WARNING", fmt.Sprintf(format, args...)) +} + +func Error(args ...interface{}) { + logger.Error(args...) + addToBuffer("ERROR", fmt.Sprint(args...)) +} + +func Errorf(format string, args ...interface{}) { + logger.Errorf(format, args...) + addToBuffer("ERROR", fmt.Sprintf(format, args...)) +} + +func addToBuffer(level string, newLog string) { + t := time.Now() + if len(logBuffer) >= 10240 { + logBuffer = logBuffer[1:] + } + + logLevel, _ := logging.LogLevel(level) + logBuffer = append(logBuffer, struct { + time string + level logging.Level + log string + }{ + time: t.Format("2006/01/02 15:04:05"), + level: logLevel, + log: newLog, + }) +} + +func GetLogs(c int, level string) []string { + var output []string + logLevel, _ := logging.LogLevel(level) + + for i := len(logBuffer) - 1; i >= 0 && len(output) <= c; i-- { + if logBuffer[i].level <= logLevel { + output = append(output, fmt.Sprintf("%s %s - %s", logBuffer[i].time, logBuffer[i].level, logBuffer[i].log)) + } + } + return output +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..11fc091 --- /dev/null +++ b/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/alireza0/s-ui/app" + "github.com/alireza0/s-ui/cmd" +) + +func runApp() { + app := app.NewApp() + + err := app.Init() + if err != nil { + log.Fatal(err) + } + + err = app.Start() + if err != nil { + log.Fatal(err) + } + + sigCh := make(chan os.Signal, 1) + // Trap shutdown signals + signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM) + for { + sig := <-sigCh + + switch sig { + case syscall.SIGHUP: + app.RestartApp() + default: + app.Stop() + return + } + } +} + +func main() { + if len(os.Args) < 2 { + runApp() + return + } else { + cmd.ParseCmd() + } +} diff --git a/middleware/domainValidator.go b/middleware/domainValidator.go new file mode 100644 index 0000000..4ef71eb --- /dev/null +++ b/middleware/domainValidator.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "net" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func DomainValidator(domain string) gin.HandlerFunc { + return func(c *gin.Context) { + host := c.Request.Host + if colonIndex := strings.LastIndex(host, ":"); colonIndex != -1 { + host, _, _ = net.SplitHostPort(c.Request.Host) + } + + if host != domain { + c.AbortWithStatus(http.StatusForbidden) + return + } + + c.Next() + } +} diff --git a/network/auto_https_conn.go b/network/auto_https_conn.go new file mode 100644 index 0000000..d1a9d52 --- /dev/null +++ b/network/auto_https_conn.go @@ -0,0 +1,67 @@ +package network + +import ( + "bufio" + "bytes" + "fmt" + "net" + "net/http" + "sync" +) + +type AutoHttpsConn struct { + net.Conn + + firstBuf []byte + bufStart int + + readRequestOnce sync.Once +} + +func NewAutoHttpsConn(conn net.Conn) net.Conn { + return &AutoHttpsConn{ + Conn: conn, + } +} + +func (c *AutoHttpsConn) readRequest() bool { + c.firstBuf = make([]byte, 2048) + n, err := c.Conn.Read(c.firstBuf) + c.firstBuf = c.firstBuf[:n] + if err != nil { + return false + } + reader := bytes.NewReader(c.firstBuf) + bufReader := bufio.NewReader(reader) + request, err := http.ReadRequest(bufReader) + if err != nil { + return false + } + resp := http.Response{ + Header: http.Header{}, + } + resp.StatusCode = http.StatusTemporaryRedirect + location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI) + resp.Header.Set("Location", location) + resp.Write(c.Conn) + c.Close() + c.firstBuf = nil + return true +} + +func (c *AutoHttpsConn) Read(buf []byte) (int, error) { + c.readRequestOnce.Do(func() { + c.readRequest() + }) + + if c.firstBuf != nil { + n := copy(buf, c.firstBuf[c.bufStart:]) + c.bufStart += n + if c.bufStart >= len(c.firstBuf) { + c.firstBuf = nil + } + return n, nil + } + + return c.Conn.Read(buf) +} diff --git a/network/auto_https_listener.go b/network/auto_https_listener.go new file mode 100644 index 0000000..2661469 --- /dev/null +++ b/network/auto_https_listener.go @@ -0,0 +1,21 @@ +package network + +import "net" + +type AutoHttpsListener struct { + net.Listener +} + +func NewAutoHttpsListener(listener net.Listener) net.Listener { + return &AutoHttpsListener{ + Listener: listener, + } +} + +func (l *AutoHttpsListener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return NewAutoHttpsConn(conn), nil +} diff --git a/runSUI.sh b/runSUI.sh new file mode 100755 index 0000000..64dc2f2 --- /dev/null +++ b/runSUI.sh @@ -0,0 +1,2 @@ +./build.sh +SUI_DB_FOLDER="db" SUI_DEBUG=true ./sui \ No newline at end of file diff --git a/s-ui.service b/s-ui.service new file mode 100644 index 0000000..31b5ecb --- /dev/null +++ b/s-ui.service @@ -0,0 +1,14 @@ +[Unit] +Description=s-ui Service +After=network.target +Wants=network.target + +[Service] +Type=simple +WorkingDirectory=/usr/local/s-ui/ +ExecStart=/usr/local/s-ui/sui +Restart=on-failure +RestartSec=10s + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/s-ui.sh b/s-ui.sh new file mode 100644 index 0000000..80c5e02 --- /dev/null +++ b/s-ui.sh @@ -0,0 +1,934 @@ +#!/bin/bash + +red='\033[0;31m' +green='\033[0;32m' +yellow='\033[0;33m' +plain='\033[0m' + +function LOGD() { + echo -e "${yellow}[DEG] $* ${plain}" +} + +function LOGE() { + echo -e "${red}[ERR] $* ${plain}" +} + +function LOGI() { + echo -e "${green}[INF] $* ${plain}" +} + +[[ $EUID -ne 0 ]] && LOGE "ERROR: You must be root to run this script! \n" && exit 1 + +if [[ -f /etc/os-release ]]; then + source /etc/os-release + release=$ID +elif [[ -f /usr/lib/os-release ]]; then + source /usr/lib/os-release + release=$ID +else + echo "Failed to check the system OS, please contact the author!" >&2 + exit 1 +fi + +echo "The OS release is: $release" + +confirm() { + if [[ $# > 1 ]]; then + echo && read -p "$1 [Default$2]: " temp + if [[ x"${temp}" == x"" ]]; then + temp=$2 + fi + else + read -p "$1 [y/n]: " temp + fi + if [[ x"${temp}" == x"y" || x"${temp}" == x"Y" ]]; then + return 0 + else + return 1 + fi +} + +confirm_restart() { + confirm "Restart the ${1} service" "y" + if [[ $? == 0 ]]; then + restart + else + show_menu + fi +} + +before_show_menu() { + echo && echo -n -e "${yellow}Press enter to return to the main menu: ${plain}" && read temp + show_menu +} + +install() { + bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/main/install.sh) + if [[ $? == 0 ]]; then + if [[ $# == 0 ]]; then + start + else + start 0 + fi + fi +} + +update() { + confirm "This function will forcefully reinstall the latest version, and the data will not be lost. Do you want to continue?" "n" + if [[ $? != 0 ]]; then + LOGE "Cancelled" + if [[ $# == 0 ]]; then + before_show_menu + fi + return 0 + fi + bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/main/install.sh) + if [[ $? == 0 ]]; then + LOGI "Update is complete, Panel has automatically restarted " + exit 0 + fi +} + +custom_version() { + echo "Enter the panel version (like 0.0.1):" + read panel_version + + if [ -z "$panel_version" ]; then + echo "Panel version cannot be empty. Exiting." + exit 1 + fi + + download_link="https://raw.githubusercontent.com/alireza0/s-ui/master/install.sh" + + install_command="bash <(curl -Ls $download_link) $panel_version" + + echo "Downloading and installing panel version $panel_version..." + eval $install_command +} + +uninstall() { + confirm "Are you sure you want to uninstall the panel?" "n" + if [[ $? != 0 ]]; then + if [[ $# == 0 ]]; then + show_menu + fi + return 0 + fi + systemctl stop s-ui + systemctl disable s-ui + rm /etc/systemd/system/s-ui.service -f + systemctl daemon-reload + systemctl reset-failed + rm /etc/s-ui/ -rf + rm /usr/local/s-ui/ -rf + + echo "" + echo -e "Uninstalled Successfully, If you want to remove this script, then after exiting the script run ${green}rm /usr/local/s-ui -f${plain} to delete it." + echo "" + + if [[ $# == 0 ]]; then + before_show_menu + fi +} + +reset_admin() { + echo "It is not recommended to set admin's credentials to default!" + confirm "Are you sure you want to reset admin's credentials to default ?" "n" + if [[ $? == 0 ]]; then + /usr/local/s-ui/sui admin -reset + fi + before_show_menu +} + +set_admin() { + echo "It is not recommended to set admin's credentials to a complex text." + read -p "Please set up your username:" config_account + read -p "Please set up your password:" config_password + /usr/local/s-ui/sui admin -username ${config_account} -password ${config_password} + before_show_menu +} + +view_admin() { + /usr/local/s-ui/sui admin -show + before_show_menu +} + +reset_setting() { + confirm "Are you sure you want to reset settings to default ?" "n" + if [[ $? == 0 ]]; then + /usr/local/s-ui/sui setting -reset + fi + before_show_menu +} + +set_setting() { + echo -e "Enter the ${yellow}panel port${plain} (leave blank for existing/default value):" + read config_port + echo -e "Enter the ${yellow}panel path${plain} (leave blank for existing/default value):" + read config_path + + echo -e "Enter the ${yellow}subscription port${plain} (leave blank for existing/default value):" + read config_subPort + echo -e "Enter the ${yellow}subscription path${plain} (leave blank for existing/default value):" + read config_subPath + + echo -e "${yellow}Initializing, please wait...${plain}" + params="" + [ -z "$config_port" ] || params="$params -port $config_port" + [ -z "$config_path" ] || params="$params -path $config_path" + [ -z "$config_subPort" ] || params="$params -subPort $config_subPort" + [ -z "$config_subPath" ] || params="$params -subPath $config_subPath" + /usr/local/s-ui/sui setting ${params} + before_show_menu +} + +view_setting() { + /usr/local/s-ui/sui setting -show + view_uri + before_show_menu +} + +view_uri() { + info=$(/usr/local/s-ui/sui uri) + if [[ $? != 0 ]]; then + LOGE "Get current uri error" + before_show_menu + fi + LOGI "You may access the Panel with following URL(s):" + echo -e "${green}${info}${plain}" +} + +start() { + check_status $1 + if [[ $? == 0 ]]; then + echo "" + LOGI -e "${1} is running, No need to start again, If you need to restart, please select restart" + else + systemctl start $1 + sleep 2 + check_status $1 + if [[ $? == 0 ]]; then + LOGI "${1} Started Successfully" + else + LOGE "Failed to start ${1}, Probably because it takes longer than two seconds to start, Please check the log information later" + fi + fi + + if [[ $# == 1 ]]; then + before_show_menu + fi +} + +stop() { + check_status $1 + if [[ $? == 1 ]]; then + echo "" + LOGI "${1} stopped, No need to stop again!" + else + systemctl stop $1 + sleep 2 + check_status + if [[ $? == 1 ]]; then + LOGI "${1} stopped successfully" + else + LOGE "Failed to stop ${1}, Probably because the stop time exceeds two seconds, Please check the log information later" + fi + fi + + if [[ $# == 1 ]]; then + before_show_menu + fi +} + +restart() { + systemctl restart $1 + sleep 2 + check_status $1 + if [[ $? == 0 ]]; then + LOGI "${1} Restarted successfully" + else + LOGE "Failed to restart ${1}, Probably because it takes longer than two seconds to start, Please check the log information later" + fi + if [[ $# == 1 ]]; then + before_show_menu + fi +} + +status() { + systemctl status s-ui -l + if [[ $# == 0 ]]; then + before_show_menu + fi +} + +enable() { + systemctl enable $1 + if [[ $? == 0 ]]; then + LOGI "Set ${1} to boot automatically on startup successfully" + else + LOGE "Failed to set ${1} Autostart" + fi + + if [[ $# == 1 ]]; then + before_show_menu + fi +} + +disable() { + systemctl disable $1 + if [[ $? == 0 ]]; then + LOGI "Autostart ${1} Cancelled successfully" + else + LOGE "Failed to cancel ${1} autostart" + fi + + if [[ $# == 1 ]]; then + before_show_menu + fi +} + +show_log() { + journalctl -u $1.service -e --no-pager -f + if [[ $# == 1 ]]; then + before_show_menu + fi +} + +update_shell() { + wget -O /usr/bin/s-ui -N --no-check-certificate https://github.com/alireza0/s-ui/raw/main/s-ui.sh + if [[ $? != 0 ]]; then + echo "" + LOGE "Failed to download script, Please check whether the machine can connect Github" + before_show_menu + else + chmod +x /usr/bin/s-ui + LOGI "Upgrade script succeeded, Please rerun the script" && exit 0 + fi +} + +check_status() { + if [[ ! -f "/etc/systemd/system/$1.service" ]]; then + return 2 + fi + temp=$(systemctl status "$1" | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1) + if [[ x"${temp}" == x"running" ]]; then + return 0 + else + return 1 + fi +} + +check_enabled() { + temp=$(systemctl is-enabled $1) + if [[ x"${temp}" == x"enabled" ]]; then + return 0 + else + return 1 + fi +} + +check_uninstall() { + check_status s-ui + if [[ $? != 2 ]]; then + echo "" + LOGE "Panel is already installed, Please do not reinstall" + if [[ $# == 0 ]]; then + before_show_menu + fi + return 1 + else + return 0 + fi +} + +check_install() { + check_status s-ui + if [[ $? == 2 ]]; then + echo "" + LOGE "Please install the panel first" + if [[ $# == 0 ]]; then + before_show_menu + fi + return 1 + else + return 0 + fi +} + +show_status() { + check_status $1 + case $? in + 0) + echo -e "${1} state: ${green}Running${plain}" + show_enable_status $1 + ;; + 1) + echo -e "${1} state: ${yellow}Not Running${plain}" + show_enable_status $1 + ;; + 2) + echo -e "${1} state: ${red}Not Installed${plain}" + ;; + esac +} + +show_enable_status() { + check_enabled $1 + if [[ $? == 0 ]]; then + echo -e "Start ${1} automatically: ${green}Yes${plain}" + else + echo -e "Start ${1} automatically: ${red}No${plain}" + fi +} + +check_s-ui_status() { + count=$(ps -ef | grep "sui" | grep -v "grep" | wc -l) + if [[ count -ne 0 ]]; then + return 0 + else + return 1 + fi +} + +show_s-ui_status() { + check_s-ui_status + if [[ $? == 0 ]]; then + echo -e "s-ui state: ${green}Running${plain}" + else + echo -e "s-ui state: ${red}Not Running${plain}" + fi +} + +bbr_menu() { + echo -e "${green}\t1.${plain} Enable BBR" + echo -e "${green}\t2.${plain} Disable BBR" + echo -e "${green}\t0.${plain} Back to Main Menu" + read -p "Choose an option: " choice + case "$choice" in + 0) + show_menu + ;; + 1) + enable_bbr + ;; + 2) + disable_bbr + ;; + *) echo "Invalid choice" ;; + esac +} + +disable_bbr() { + if ! grep -q "net.core.default_qdisc=fq" /etc/sysctl.conf || ! grep -q "net.ipv4.tcp_congestion_control=bbr" /etc/sysctl.conf; then + echo -e "${yellow}BBR is not currently enabled.${plain}" + exit 0 + fi + sed -i 's/net.core.default_qdisc=fq/net.core.default_qdisc=pfifo_fast/' /etc/sysctl.conf + sed -i 's/net.ipv4.tcp_congestion_control=bbr/net.ipv4.tcp_congestion_control=cubic/' /etc/sysctl.conf + sysctl -p + if [[ $(sysctl net.ipv4.tcp_congestion_control | awk '{print $3}') == "cubic" ]]; then + echo -e "${green}BBR has been replaced with CUBIC successfully.${plain}" + else + echo -e "${red}Failed to replace BBR with CUBIC. Please check your system configuration.${plain}" + fi +} + +enable_bbr() { + if grep -q "net.core.default_qdisc=fq" /etc/sysctl.conf && grep -q "net.ipv4.tcp_congestion_control=bbr" /etc/sysctl.conf; then + echo -e "${green}BBR is already enabled!${plain}" + exit 0 + fi + case "${release}" in + ubuntu | debian | armbian) + apt-get update && apt-get install -yqq --no-install-recommends ca-certificates + ;; + centos | almalinux | rocky | oracle) + yum -y update && yum -y install ca-certificates + ;; + fedora) + dnf -y update && dnf -y install ca-certificates + ;; + arch | manjaro | parch) + pacman -Sy --noconfirm ca-certificates + ;; + *) + echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n" + exit 1 + ;; + esac + echo "net.core.default_qdisc=fq" | tee -a /etc/sysctl.conf + echo "net.ipv4.tcp_congestion_control=bbr" | tee -a /etc/sysctl.conf + sysctl -p + if [[ $(sysctl net.ipv4.tcp_congestion_control | awk '{print $3}') == "bbr" ]]; then + echo -e "${green}BBR has been enabled successfully.${plain}" + else + echo -e "${red}Failed to enable BBR. Please check your system configuration.${plain}" + fi +} + +install_acme() { + cd ~ + LOGI "install acme..." + curl https://get.acme.sh | sh + if [ $? -ne 0 ]; then + LOGE "install acme failed" + return 1 + else + LOGI "install acme succeed" + fi + return 0 +} + +ssl_cert_issue_main() { + echo -e "${green}\t1.${plain} Get SSL" + echo -e "${green}\t2.${plain} Revoke" + echo -e "${green}\t3.${plain} Force Renew" + echo -e "${green}\t4.${plain} Self-signed Certificate" + read -p "Choose an option: " choice + case "$choice" in + 1) ssl_cert_issue ;; + 2) + local domain="" + read -p "Please enter your domain name to revoke the certificate: " domain + ~/.acme.sh/acme.sh --revoke -d ${domain} + LOGI "Certificate revoked" + ;; + 3) + local domain="" + read -p "Please enter your domain name to forcefully renew an SSL certificate: " domain + ~/.acme.sh/acme.sh --renew -d ${domain} --force ;; + 4) + generate_self_signed_cert + ;; + *) echo "Invalid choice" ;; + esac +} + +ssl_cert_issue() { + if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then + echo "acme.sh could not be found. we will install it" + install_acme + if [ $? -ne 0 ]; then + LOGE "install acme failed, please check logs" + exit 1 + fi + fi + case "${release}" in + ubuntu | debian | armbian) + apt update && apt install socat -y + ;; + centos | almalinux | rocky | oracle) + yum -y update && yum -y install socat + ;; + fedora) + dnf -y update && dnf -y install socat + ;; + arch | manjaro | parch) + pacman -Sy --noconfirm socat + ;; + *) + echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n" + exit 1 + ;; + esac + if [ $? -ne 0 ]; then + LOGE "install socat failed, please check logs" + exit 1 + else + LOGI "install socat succeed..." + fi + + local domain="" + read -p "Please enter your domain name:" domain + LOGD "your domain is:${domain},check it..." + local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}') + + if [ ${currentCert} == ${domain} ]; then + local certInfo=$(~/.acme.sh/acme.sh --list) + LOGE "system already has certs here,can not issue again,current certs details:" + LOGI "$certInfo" + exit 1 + else + LOGI "your domain is ready for issuing cert now..." + fi + + certPath="/root/cert/${domain}" + if [ ! -d "$certPath" ]; then + mkdir -p "$certPath" + else + rm -rf "$certPath" + mkdir -p "$certPath" + fi + + local WebPort=80 + read -p "please choose which port do you use,default will be 80 port:" WebPort + if [[ ${WebPort} -gt 65535 || ${WebPort} -lt 1 ]]; then + LOGE "your input ${WebPort} is invalid,will use default port" + fi + LOGI "will use port:${WebPort} to issue certs,please make sure this port is open..." + ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt + ~/.acme.sh/acme.sh --issue -d ${domain} --standalone --httpport ${WebPort} + if [ $? -ne 0 ]; then + LOGE "issue certs failed,please check logs" + rm -rf ~/.acme.sh/${domain} + exit 1 + else + LOGE "issue certs succeed,installing certs..." + fi + ~/.acme.sh/acme.sh --installcert -d ${domain} \ + --key-file /root/cert/${domain}/privkey.pem \ + --fullchain-file /root/cert/${domain}/fullchain.pem + + if [ $? -ne 0 ]; then + LOGE "install certs failed,exit" + rm -rf ~/.acme.sh/${domain} + exit 1 + else + LOGI "install certs succeed,enable auto renew..." + fi + + ~/.acme.sh/acme.sh --upgrade --auto-upgrade + if [ $? -ne 0 ]; then + LOGE "auto renew failed, certs details:" + ls -lah cert/* + chmod 755 $certPath/* + exit 1 + else + LOGI "auto renew succeed, certs details:" + ls -lah cert/* + chmod 755 $certPath/* + fi +} + +ssl_cert_issue_CF() { + echo -E "" + LOGD "******Instructions for use******" + echo "1) New certificate from Cloudflare" + echo "2) Force renew existing Certificates" + echo "3) Back to Menu" + read -p "Enter your choice [1-3]: " choice + + certPath="/root/cert-CF" + + case $choice in + 1|2) + force_flag="" + if [ "$choice" -eq 2 ]; then + force_flag="--force" + echo "Forcing SSL certificate reissuance..." + else + echo "Starting SSL certificate issuance..." + fi + + LOGD "******Instructions for use******" + LOGI "This Acme script requires the following data:" + LOGI "1.Cloudflare Registered e-mail" + LOGI "2.Cloudflare Global API Key" + LOGI "3.The domain name that has been resolved DNS to the current server by Cloudflare" + LOGI "4.The script applies for a certificate. The default installation path is /root/cert " + confirm "Confirmed?[y/n]" "y" + if [ $? -eq 0 ]; then + if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then + echo "acme.sh could not be found. Installing..." + install_acme + if [ $? -ne 0 ]; then + LOGE "Install acme failed, please check logs" + exit 1 + fi + fi + + CF_Domain="" + if [ ! -d "$certPath" ]; then + mkdir -p $certPath + else + rm -rf $certPath + mkdir -p $certPath + fi + + LOGD "Please set a domain name:" + read -p "Input your domain here: " CF_Domain + LOGD "Your domain name is set to: ${CF_Domain}" + + CF_GlobalKey="" + CF_AccountEmail="" + LOGD "Please set the API key:" + read -p "Input your key here: " CF_GlobalKey + LOGD "Your API key is: ${CF_GlobalKey}" + + LOGD "Please set up registered email:" + read -p "Input your email here: " CF_AccountEmail + LOGD "Your registered email address is: ${CF_AccountEmail}" + + ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt + if [ $? -ne 0 ]; then + LOGE "Default CA, Let's Encrypt failed, script exiting..." + exit 1 + fi + + export CF_Key="${CF_GlobalKey}" + export CF_Email="${CF_AccountEmail}" + + ~/.acme.sh/acme.sh --issue --dns dns_cf -d ${CF_Domain} -d *.${CF_Domain} $force_flag --log + if [ $? -ne 0 ]; then + LOGE "Certificate issuance failed, script exiting..." + exit 1 + else + LOGI "Certificate issued Successfully, Installing..." + fi + + mkdir -p ${certPath}/${CF_Domain} + if [ $? -ne 0 ]; then + LOGE "Failed to create directory: ${certPath}/${CF_Domain}" + exit 1 + fi + + ~/.acme.sh/acme.sh --installcert -d ${CF_Domain} -d *.${CF_Domain} \ + --fullchain-file ${certPath}/${CF_Domain}/fullchain.pem \ + --key-file ${certPath}/${CF_Domain}/privkey.pem + + if [ $? -ne 0 ]; then + LOGE "Certificate installation failed, script exiting..." + exit 1 + else + LOGI "Certificate installed Successfully, Turning on automatic updates..." + fi + + ~/.acme.sh/acme.sh --upgrade --auto-upgrade + if [ $? -ne 0 ]; then + LOGE "Auto update setup failed, script exiting..." + exit 1 + else + LOGI "The certificate is installed and auto-renewal is turned on." + ls -lah ${certPath}/${CF_Domain} + chmod 755 ${certPath}/${CF_Domain} + fi + fi + show_menu + ;; + 3) + echo "Exiting..." + show_menu + ;; + *) + echo "Invalid choice, please select again." + show_menu + ;; + esac +} + +generate_self_signed_cert() { + cert_dir="/etc/sing-box" + mkdir -p "$cert_dir" + LOGI "Choose certificate type:" + echo -e "${green}\t1.${plain} Ed25519 (*recommended*)" + echo -e "${green}\t2.${plain} RSA 2048" + echo -e "${green}\t3.${plain} RSA 4096" + echo -e "${green}\t4.${plain} ECDSA prime256v1" + echo -e "${green}\t5.${plain} ECDSA secp384r1" + read -p "Enter your choice [1-5, default 1]: " cert_type + cert_type=${cert_type:-1} + + case "$cert_type" in + 1) + algo="ed25519" + key_opt="-newkey ed25519" + ;; + 2) + algo="rsa" + key_opt="-newkey rsa:2048" + ;; + 3) + algo="rsa" + key_opt="-newkey rsa:4096" + ;; + 4) + algo="ecdsa" + key_opt="-newkey ec -pkeyopt ec_paramgen_curve:prime256v1" + ;; + 5) + algo="ecdsa" + key_opt="-newkey ec -pkeyopt ec_paramgen_curve:secp384r1" + ;; + *) + algo="ed25519" + key_opt="-newkey ed25519" + ;; + esac + + LOGI "Generating self-signed certificate ($algo)..." + sudo openssl req -x509 -nodes -days 3650 $key_opt \ + -keyout "${cert_dir}/self.key" \ + -out "${cert_dir}/self.crt" \ + -subj "/CN=myserver" + if [[ $? -eq 0 ]]; then + sudo chmod 600 "${cert_dir}/self."* + LOGI "Self-signed certificate generated successfully!" + LOGI "Certificate path: ${cert_dir}/self.crt" + LOGI "Key path: ${cert_dir}/self.key" + else + LOGE "Failed to generate self-signed certificate." + fi + before_show_menu +} + +show_usage() { + echo -e "S-UI Control Menu Usage" + echo -e "------------------------------------------" + echo -e "SUBCOMMANDS:" + echo -e "s-ui - Admin Management Script" + echo -e "s-ui start - Start s-ui" + echo -e "s-ui stop - Stop s-ui" + echo -e "s-ui restart - Restart s-ui" + echo -e "s-ui status - Current Status of s-ui" + echo -e "s-ui enable - Enable Autostart on OS Startup" + echo -e "s-ui disable - Disable Autostart on OS Startup" + echo -e "s-ui log - Check s-ui Logs" + echo -e "s-ui update - Update" + echo -e "s-ui install - Install" + echo -e "s-ui uninstall - Uninstall" + echo -e "s-ui help - Control Menu Usage" + echo -e "------------------------------------------" +} + +show_menu() { + echo -e " + ${green}S-UI Admin Management Script ${plain} +———————————————————————————————— + ${green}0.${plain} Exit +———————————————————————————————— + ${green}1.${plain} Install + ${green}2.${plain} Update + ${green}3.${plain} Custom Version + ${green}4.${plain} Uninstall +———————————————————————————————— + ${green}5.${plain} Reset admin credentials to default + ${green}6.${plain} Set admin credentials + ${green}7.${plain} View admin credentials +———————————————————————————————— + ${green}8.${plain} Reset Panel Settings + ${green}9.${plain} Set Panel settings + ${green}10.${plain} View Panel Settings +———————————————————————————————— + ${green}11.${plain} S-UI Start + ${green}12.${plain} S-UI Stop + ${green}13.${plain} S-UI Restart + ${green}14.${plain} S-UI Check State + ${green}15.${plain} S-UI Check Logs + ${green}16.${plain} S-UI Enable Autostart + ${green}17.${plain} S-UI Disable Autostart +———————————————————————————————— + ${green}18.${plain} Enable or Disable BBR + ${green}19.${plain} SSL Certificate Management + ${green}20.${plain} Cloudflare SSL Certificate +———————————————————————————————— + " + show_status s-ui + echo && read -p "Please enter your selection [0-20]: " num + + case "${num}" in + 0) + exit 0 + ;; + 1) + check_uninstall && install + ;; + 2) + check_install && update + ;; + 3) + check_install && custom_version + ;; + 4) + check_install && uninstall + ;; + 5) + check_install && reset_admin + ;; + 6) + check_install && set_admin + ;; + 7) + check_install && view_admin + ;; + 8) + check_install && reset_setting + ;; + 9) + check_install && set_setting + ;; + 10) + check_install && view_setting + ;; + 11) + check_install && start s-ui + ;; + 12) + check_install && stop s-ui + ;; + 13) + check_install && restart s-ui + ;; + 14) + check_install && status s-ui + ;; + 15) + check_install && show_log s-ui + ;; + 16) + check_install && enable s-ui + ;; + 17) + check_install && disable s-ui + ;; + 18) + bbr_menu + ;; + 19) + ssl_cert_issue_main + ;; + 20) + ssl_cert_issue_CF + ;; + *) + LOGE "Please enter the correct number [0-20]" + ;; + esac +} + +if [[ $# > 0 ]]; then + case $1 in + "start") + check_install 0 && start s-ui 0 + ;; + "stop") + check_install 0 && stop s-ui 0 + ;; + "restart") + check_install 0 && restart s-ui 0 + ;; + "status") + check_install 0 && status 0 + ;; + "enable") + check_install 0 && enable s-ui 0 + ;; + "disable") + check_install 0 && disable s-ui 0 + ;; + "log") + check_install 0 && show_log s-ui 0 + ;; + "update") + check_install 0 && update 0 + ;; + "install") + check_uninstall 0 && install 0 + ;; + "uninstall") + check_install 0 && uninstall 0 + ;; + *) show_usage ;; + esac +else + show_menu +fi diff --git a/service/client.go b/service/client.go new file mode 100644 index 0000000..70be8dc --- /dev/null +++ b/service/client.go @@ -0,0 +1,548 @@ +package service + +import ( + "bytes" + "encoding/json" + "strings" + "time" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util" + "github.com/alireza0/s-ui/util/common" + + "gorm.io/gorm" +) + +type ClientService struct{} + +func (s *ClientService) Get(id string) (*[]model.Client, error) { + if id == "" { + return s.GetAll() + } + return s.getById(id) +} + +func (s *ClientService) getById(id string) (*[]model.Client, error) { + db := database.GetDB() + var client []model.Client + err := db.Model(model.Client{}).Where("id in ?", strings.Split(id, ",")).Scan(&client).Error + if err != nil { + return nil, err + } + + return &client, nil +} + +func (s *ClientService) GetAll() (*[]model.Client, error) { + db := database.GetDB() + var clients []model.Client + err := db.Model(model.Client{}). + Select("`id`, `enable`, `name`, `desc`, `group`, `inbounds`, `up`, `down`, `volume`, `expiry`"). + Scan(&clients).Error + if err != nil { + return nil, err + } + return &clients, nil +} + +func (s *ClientService) Save(tx *gorm.DB, act string, data json.RawMessage, hostname string) ([]uint, error) { + var err error + var inboundIds []uint + + switch act { + case "new", "edit": + var client model.Client + err = json.Unmarshal(data, &client) + if err != nil { + return nil, err + } + err = s.updateLinksWithFixedInbounds(tx, []*model.Client{&client}, hostname) + if err != nil { + return nil, err + } + if act == "edit" { + // Find changed inbounds + inboundIds, err = s.findInboundsChanges(tx, &client, false) + if err != nil { + return nil, err + } + } else { + err = json.Unmarshal(client.Inbounds, &inboundIds) + if err != nil { + return nil, err + } + } + err = tx.Save(&client).Error + if err != nil { + return nil, err + } + case "addbulk": + var clients []*model.Client + err = json.Unmarshal(data, &clients) + if err != nil { + return nil, err + } + err = json.Unmarshal(clients[0].Inbounds, &inboundIds) + if err != nil { + return nil, err + } + err = s.updateLinksWithFixedInbounds(tx, clients, hostname) + if err != nil { + return nil, err + } + err = tx.Save(clients).Error + if err != nil { + return nil, err + } + case "editbulk": + var clients []*model.Client + err = json.Unmarshal(data, &clients) + if err != nil { + return nil, err + } + for _, client := range clients { + changedInboundIds, err := s.findInboundsChanges(tx, client, true) + if err != nil { + return nil, err + } + if len(changedInboundIds) > 0 { + inboundIds = common.UnionUintArray(inboundIds, changedInboundIds) + } + } + if len(inboundIds) > 0 { + err = s.updateLinksWithFixedInbounds(tx, clients, hostname) + if err != nil { + return nil, err + } + } + err = tx.Save(clients).Error + if err != nil { + return nil, err + } + case "delbulk": + var ids []uint + err = json.Unmarshal(data, &ids) + if err != nil { + return nil, err + } + for _, id := range ids { + var client model.Client + err = tx.Where("id = ?", id).First(&client).Error + if err != nil { + return nil, err + } + var clientInbounds []uint + err = json.Unmarshal(client.Inbounds, &clientInbounds) + if err != nil { + return nil, err + } + inboundIds = common.UnionUintArray(inboundIds, clientInbounds) + } + err = tx.Where("id in ?", ids).Delete(model.Client{}).Error + if err != nil { + return nil, err + } + case "del": + var id uint + err = json.Unmarshal(data, &id) + if err != nil { + return nil, err + } + var client model.Client + err = tx.Where("id = ?", id).First(&client).Error + if err != nil { + return nil, err + } + err = json.Unmarshal(client.Inbounds, &inboundIds) + if err != nil { + return nil, err + } + err = tx.Where("id = ?", id).Delete(model.Client{}).Error + if err != nil { + return nil, err + } + default: + return nil, common.NewErrorf("unknown action: %s", act) + } + + return inboundIds, nil +} + +func (s *ClientService) updateLinksWithFixedInbounds(tx *gorm.DB, clients []*model.Client, hostname string) error { + var err error + var inbounds []model.Inbound + var inboundIds []uint + + err = json.Unmarshal(clients[0].Inbounds, &inboundIds) + if err != nil { + return err + } + + // Zero inbounds means removing local links only + if len(inboundIds) > 0 { + err = tx.Model(model.Inbound{}).Preload("Tls").Where("id in ? and type in ?", inboundIds, util.InboundTypeWithLink).Find(&inbounds).Error + if err != nil { + return err + } + } + for index, client := range clients { + var clientLinks []map[string]string + err = json.Unmarshal(client.Links, &clientLinks) + if err != nil { + return err + } + + newClientLinks := []map[string]string{} + for _, inbound := range inbounds { + newLinks := util.LinkGenerator(client.Config, &inbound, hostname) + for _, newLink := range newLinks { + newClientLinks = append(newClientLinks, map[string]string{ + "remark": inbound.Tag, + "type": "local", + "uri": newLink, + }) + } + } + + // Add non local links + for _, clientLink := range clientLinks { + if clientLink["type"] != "local" { + newClientLinks = append(newClientLinks, clientLink) + } + } + + clients[index].Links, err = json.MarshalIndent(newClientLinks, "", " ") + if err != nil { + return err + } + } + return nil +} + +func (s *ClientService) UpdateClientsOnInboundAdd(tx *gorm.DB, initIds string, inboundId uint, hostname string) error { + clientIds := strings.Split(initIds, ",") + var clients []model.Client + err := tx.Model(model.Client{}).Where("id in ?", clientIds).Find(&clients).Error + if err != nil { + return err + } + var inbound model.Inbound + err = tx.Model(model.Inbound{}).Preload("Tls").Where("id = ?", inboundId).Find(&inbound).Error + if err != nil { + return err + } + for _, client := range clients { + // Add inbounds + var clientInbounds []uint + json.Unmarshal(client.Inbounds, &clientInbounds) + clientInbounds = append(clientInbounds, inboundId) + client.Inbounds, err = json.MarshalIndent(clientInbounds, "", " ") + if err != nil { + return err + } + // Add links + var clientLinks, newClientLinks []map[string]string + json.Unmarshal(client.Links, &clientLinks) + newLinks := util.LinkGenerator(client.Config, &inbound, hostname) + for _, newLink := range newLinks { + newClientLinks = append(newClientLinks, map[string]string{ + "remark": inbound.Tag, + "type": "local", + "uri": newLink, + }) + } + for _, clientLink := range clientLinks { + if clientLink["remark"] != inbound.Tag { + newClientLinks = append(newClientLinks, clientLink) + } + } + + client.Links, err = json.MarshalIndent(newClientLinks, "", " ") + if err != nil { + return err + } + err = tx.Save(&client).Error + if err != nil { + return err + } + } + return nil +} + +func (s *ClientService) UpdateClientsOnInboundDelete(tx *gorm.DB, id uint, tag string) error { + var clientIds []uint + err := tx.Raw("SELECT clients.id FROM clients, json_each(clients.inbounds) AS je WHERE je.value = ?", id).Scan(&clientIds).Error + if err != nil { + return err + } + if len(clientIds) == 0 { + return nil + } + var clients []model.Client + err = tx.Model(model.Client{}).Where("id IN ?", clientIds).Find(&clients).Error + if err != nil { + return err + } + for _, client := range clients { + // Delete inbounds + var clientInbounds, newClientInbounds []uint + json.Unmarshal(client.Inbounds, &clientInbounds) + for _, clientInbound := range clientInbounds { + if clientInbound != id { + newClientInbounds = append(newClientInbounds, clientInbound) + } + } + client.Inbounds, err = json.MarshalIndent(newClientInbounds, "", " ") + if err != nil { + return err + } + // Delete links + var clientLinks, newClientLinks []map[string]string + json.Unmarshal(client.Links, &clientLinks) + for _, clientLink := range clientLinks { + if clientLink["remark"] != tag { + newClientLinks = append(newClientLinks, clientLink) + } + } + client.Links, err = json.MarshalIndent(newClientLinks, "", " ") + if err != nil { + return err + } + err = tx.Save(&client).Error + if err != nil { + return err + } + } + return nil +} + +func (s *ClientService) UpdateLinksByInboundChange(tx *gorm.DB, inbounds *[]model.Inbound, hostname string, oldTag string) error { + var err error + for _, inbound := range *inbounds { + var clientIds []uint + err = tx.Raw("SELECT clients.id FROM clients, json_each(clients.inbounds) AS je WHERE je.value = ?", inbound.Id).Scan(&clientIds).Error + if err != nil { + return err + } + if len(clientIds) == 0 { + continue + } + var clients []model.Client + err = tx.Model(model.Client{}).Where("id IN ?", clientIds).Find(&clients).Error + if err != nil { + return err + } + for _, client := range clients { + var clientLinks, newClientLinks []map[string]string + json.Unmarshal(client.Links, &clientLinks) + newLinks := util.LinkGenerator(client.Config, &inbound, hostname) + for _, newLink := range newLinks { + newClientLinks = append(newClientLinks, map[string]string{ + "remark": inbound.Tag, + "type": "local", + "uri": newLink, + }) + } + for _, clientLink := range clientLinks { + if clientLink["type"] != "local" || (clientLink["remark"] != inbound.Tag && clientLink["remark"] != oldTag) { + newClientLinks = append(newClientLinks, clientLink) + } + } + + client.Links, err = json.MarshalIndent(newClientLinks, "", " ") + if err != nil { + return err + } + err = tx.Save(&client).Error + if err != nil { + return err + } + } + } + return nil +} + +func (s *ClientService) DepleteClients() ([]uint, error) { + var err error + var clients []model.Client + var changes []model.Changes + var users []string + var inboundIds []uint + + dt := time.Now().Unix() + db := database.GetDB() + + tx := db.Begin() + defer func() { + if err == nil { + tx.Commit() + if err1 := db.Exec("PRAGMA wal_checkpoint(FULL)").Error; err1 != nil { + logger.Error("Error checkpointing WAL: ", err1.Error()) + } + } else { + tx.Rollback() + } + }() + + // Reset clients + inboundIds, err = s.ResetClients(tx, dt) + if err != nil { + return nil, err + } + + // Deplete clients + err = tx.Model(model.Client{}).Where("enable = true AND ((volume >0 AND up+down > volume) OR (expiry > 0 AND expiry < ?))", dt).Scan(&clients).Error + if err != nil { + return nil, err + } + + for _, client := range clients { + logger.Debug("Client ", client.Name, " is going to be disabled") + users = append(users, client.Name) + var userInbounds []uint + json.Unmarshal(client.Inbounds, &userInbounds) + // Find changed inbounds + inboundIds = common.UnionUintArray(inboundIds, userInbounds) + changes = append(changes, model.Changes{ + DateTime: dt, + Actor: "DepleteJob", + Key: "clients", + Action: "disable", + Obj: json.RawMessage("\"" + client.Name + "\""), + }) + } + + // Save changes + if len(changes) > 0 { + err = tx.Model(model.Client{}).Where("enable = true AND ((volume >0 AND up+down > volume) OR (expiry > 0 AND expiry < ?))", dt).Update("enable", false).Error + if err != nil { + return nil, err + } + err = tx.Model(model.Changes{}).Create(&changes).Error + if err != nil { + return nil, err + } + LastUpdate = dt + } + + return inboundIds, nil +} + +func (s *ClientService) ResetClients(tx *gorm.DB, dt int64) ([]uint, error) { + var err error + var resetClients, allClients []*model.Client + var changes []model.Changes + var inboundIds []uint + // Set delay start without periodic reset + err = tx.Model(model.Client{}). + Where("enable = true AND delay_start = true AND auto_reset = false AND (Up + Down) > 0").Find(&resetClients).Error + if err != nil { + return nil, err + } + for _, client := range resetClients { + client.Expiry = dt + (int64(client.ResetDays) * 86400) + client.DelayStart = false + changes = append(changes, model.Changes{ + DateTime: dt, + Actor: "ResetJob", + Key: "clients", + Action: "reset", + Obj: json.RawMessage("\"" + client.Name + "\""), + }) + } + allClients = append(allClients, resetClients...) + + // Set delay start with periodic reset + err = tx.Model(model.Client{}). + Where("enable = true AND delay_start = true AND auto_reset = true AND (Up + Down) > 0").Find(&resetClients).Error + if err != nil { + return nil, err + } + for _, client := range resetClients { + client.NextReset = dt + (int64(client.ResetDays) * 86400) + client.DelayStart = false + changes = append(changes, model.Changes{ + DateTime: dt, + Actor: "ResetJob", + Key: "clients", + Action: "reset", + Obj: json.RawMessage("\"" + client.Name + "\""), + }) + } + allClients = append(allClients, resetClients...) + + // Set periodic reset + err = tx.Model(model.Client{}). + Where("delay_start = false AND auto_reset = true AND next_reset < ?", dt).Find(&resetClients).Error + if err != nil { + return nil, err + } + for _, client := range resetClients { + client.NextReset = dt + (int64(client.ResetDays) * 86400) + client.TotalUp += client.Up + client.TotalDown += client.Down + client.Up = 0 + client.Down = 0 + if !client.Enable { + client.Enable = true + var clientInboundIds []uint + json.Unmarshal(client.Inbounds, &clientInboundIds) + inboundIds = common.UnionUintArray(inboundIds, clientInboundIds) + } + } + allClients = append(allClients, resetClients...) + + // Save clients + if len(allClients) > 0 { + err = tx.Save(allClients).Error + if err != nil { + return nil, err + } + } + + // Save changes + if len(changes) > 0 { + err = tx.Model(model.Changes{}).Create(&changes).Error + if err != nil { + return nil, err + } + LastUpdate = dt + } + return inboundIds, nil +} + +func (s *ClientService) findInboundsChanges(tx *gorm.DB, client *model.Client, fillOmitted bool) ([]uint, error) { + var err error + var oldClient model.Client + var oldInboundIds, newInboundIds []uint + err = tx.Model(model.Client{}).Where("id = ?", client.Id).First(&oldClient).Error + if err != nil { + return nil, err + } + if fillOmitted { + client.Links = oldClient.Links + client.Config = oldClient.Config + } + err = json.Unmarshal(oldClient.Inbounds, &oldInboundIds) + if err != nil { + return nil, err + } + err = json.Unmarshal(client.Inbounds, &newInboundIds) + if err != nil { + return nil, err + } + + // Check client.Config changes + if !bytes.Equal(oldClient.Config, client.Config) || + oldClient.Name != client.Name || + oldClient.Enable != client.Enable { + return common.UnionUintArray(oldInboundIds, newInboundIds), nil + } + + // Check client.Inbounds changes + diffInbounds := common.DiffUintArray(oldInboundIds, newInboundIds) + + return diffInbounds, nil +} diff --git a/service/config.go b/service/config.go new file mode 100644 index 0000000..91ec07d --- /dev/null +++ b/service/config.go @@ -0,0 +1,297 @@ +package service + +import ( + "encoding/json" + "strconv" + "sync" + "time" + + "github.com/alireza0/s-ui/core" + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" +) + +var ( + LastUpdate int64 + corePtr *core.Core + startCoreMu sync.Mutex + startCoreInProgress bool + lastStartFailTime time.Time + startCooldown = 15 * time.Second +) + +type ConfigService struct { + ClientService + TlsService + SettingService + InboundService + OutboundService + ServicesService + EndpointService +} + +type SingBoxConfig struct { + Log json.RawMessage `json:"log"` + Dns json.RawMessage `json:"dns"` + Ntp json.RawMessage `json:"ntp"` + Inbounds []json.RawMessage `json:"inbounds"` + Outbounds []json.RawMessage `json:"outbounds"` + Services []json.RawMessage `json:"services"` + Endpoints []json.RawMessage `json:"endpoints"` + Route json.RawMessage `json:"route"` + Experimental json.RawMessage `json:"experimental"` +} + +func NewConfigService(core *core.Core) *ConfigService { + corePtr = core + return &ConfigService{} +} + +func (s *ConfigService) GetConfig(data string) (*[]byte, error) { + var err error + if len(data) == 0 { + data, err = s.SettingService.GetConfig() + if err != nil { + return nil, err + } + } + singboxConfig := SingBoxConfig{} + err = json.Unmarshal([]byte(data), &singboxConfig) + if err != nil { + return nil, err + } + + singboxConfig.Inbounds, err = s.InboundService.GetAllConfig(database.GetDB()) + if err != nil { + return nil, err + } + singboxConfig.Outbounds, err = s.OutboundService.GetAllConfig(database.GetDB()) + if err != nil { + return nil, err + } + singboxConfig.Services, err = s.ServicesService.GetAllConfig(database.GetDB()) + if err != nil { + return nil, err + } + singboxConfig.Endpoints, err = s.EndpointService.GetAllConfig(database.GetDB()) + if err != nil { + return nil, err + } + rawConfig, err := json.MarshalIndent(singboxConfig, "", " ") + if err != nil { + return nil, err + } + return &rawConfig, nil +} + +func (s *ConfigService) StartCore() error { + if corePtr.IsRunning() { + return nil + } + startCoreMu.Lock() + if startCoreInProgress { + startCoreMu.Unlock() + return nil + } + if time.Since(lastStartFailTime) < startCooldown { + logger.Info("start core cooldown ", startCooldown/time.Second, " seconds") + startCoreMu.Unlock() + return nil + } + startCoreInProgress = true + startCoreMu.Unlock() + defer func() { + startCoreMu.Lock() + startCoreInProgress = false + startCoreMu.Unlock() + }() + + logger.Info("starting core") + rawConfig, err := s.GetConfig("") + if err != nil { + return err + } + err = corePtr.Start(*rawConfig) + if err != nil { + startCoreMu.Lock() + lastStartFailTime = time.Now() + startCoreMu.Unlock() + logger.Error("start sing-box err:", err.Error()) + return err + } + logger.Info("sing-box started") + return nil +} + +func (s *ConfigService) RestartCore() error { + err := s.StopCore() + if err != nil { + return err + } + return s.StartCore() +} + +func (s *ConfigService) restartCoreWithConfig(config json.RawMessage) error { + startCoreMu.Lock() + if startCoreInProgress { + startCoreMu.Unlock() + return nil + } + startCoreInProgress = true + startCoreMu.Unlock() + defer func() { + startCoreMu.Lock() + startCoreInProgress = false + startCoreMu.Unlock() + }() + + if corePtr.IsRunning() { + if err := corePtr.Stop(); err != nil { + logger.Error("restart sing-box err (stop):", err.Error()) + return err + } + } + rawConfig, err := s.GetConfig(string(config)) + if err != nil { + logger.Error("restart sing-box err (get config):", err.Error()) + return err + } + if err := corePtr.Start(*rawConfig); err != nil { + logger.Error("restart sing-box err (start):", err.Error()) + return err + } + logger.Info("sing-box restarted with new config") + return nil +} + +func (s *ConfigService) StopCore() error { + err := corePtr.Stop() + if err != nil { + return err + } + logger.Info("sing-box stopped") + return nil +} + +func (s *ConfigService) CheckOutbound(tag string, link string) core.CheckOutboundResult { + if tag == "" { + return core.CheckOutboundResult{Error: "missing query parameter: tag"} + } + if corePtr == nil || !corePtr.IsRunning() { + return core.CheckOutboundResult{Error: "core not running"} + } + return core.CheckOutbound(corePtr.GetCtx(), tag, link) +} + +func (s *ConfigService) Save(obj string, act string, data json.RawMessage, initUsers string, loginUser string, hostname string) ([]string, error) { + var err error + var objs []string = []string{obj} + + db := database.GetDB() + tx := db.Begin() + defer func() { + if err == nil { + tx.Commit() + // Try to start core if it is not running + if !corePtr.IsRunning() { + s.StartCore() + } + } else { + tx.Rollback() + } + }() + + switch obj { + case "clients": + var inboundIds []uint + inboundIds, err = s.ClientService.Save(tx, act, data, hostname) + if err == nil && len(inboundIds) > 0 { + objs = append(objs, "inbounds") + err = s.InboundService.RestartInbounds(tx, inboundIds) + if err != nil { + return nil, common.NewErrorf("failed to update users for inbounds: %v", err) + } + } + case "tls": + err = s.TlsService.Save(tx, act, data, hostname) + objs = append(objs, "clients", "inbounds") + case "inbounds": + err = s.InboundService.Save(tx, act, data, initUsers, hostname) + objs = append(objs, "clients") + case "outbounds": + err = s.OutboundService.Save(tx, act, data) + case "services": + err = s.ServicesService.Save(tx, act, data) + case "endpoints": + err = s.EndpointService.Save(tx, act, data) + case "config": + err = s.SettingService.SaveConfig(tx, data) + if err != nil { + return nil, err + } + configData := make(json.RawMessage, len(data)) + copy(configData, data) + go func() { _ = s.restartCoreWithConfig(configData) }() + case "settings": + err = s.SettingService.Save(tx, data) + default: + return nil, common.NewError("unknown object: ", obj) + } + if err != nil { + return nil, err + } + + dt := time.Now().Unix() + err = tx.Create(&model.Changes{ + DateTime: dt, + Actor: loginUser, + Key: obj, + Action: act, + Obj: data, + }).Error + if err != nil { + return nil, err + } + + LastUpdate = time.Now().Unix() + + return objs, nil +} + +func (s *ConfigService) CheckChanges(lu string) (bool, error) { + if lu == "" { + return true, nil + } + if LastUpdate == 0 { + db := database.GetDB() + var count int64 + err := db.Model(model.Changes{}).Where("date_time > " + lu).Count(&count).Error + if err == nil { + LastUpdate = time.Now().Unix() + } + return count > 0, err + } else { + intLu, err := strconv.ParseInt(lu, 10, 64) + return LastUpdate > intLu, err + } +} + +func (s *ConfigService) GetChanges(actor string, chngKey string, count string) []model.Changes { + c, _ := strconv.Atoi(count) + whereString := "`id`>0" + if len(actor) > 0 { + whereString += " and `actor`='" + actor + "'" + } + if len(chngKey) > 0 { + whereString += " and `key`='" + chngKey + "'" + } + db := database.GetDB() + var chngs []model.Changes + err := db.Model(model.Changes{}).Where(whereString).Order("`id` desc").Limit(c).Scan(&chngs).Error + if err != nil { + logger.Warning(err) + } + return chngs +} diff --git a/service/endpoints.go b/service/endpoints.go new file mode 100644 index 0000000..4b6d991 --- /dev/null +++ b/service/endpoints.go @@ -0,0 +1,140 @@ +package service + +import ( + "encoding/json" + "os" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/util/common" + + "gorm.io/gorm" +) + +type EndpointService struct { + WarpService +} + +func (o *EndpointService) GetAll() (*[]map[string]interface{}, error) { + db := database.GetDB() + endpoints := []*model.Endpoint{} + err := db.Model(model.Endpoint{}).Scan(&endpoints).Error + if err != nil { + return nil, err + } + var data []map[string]interface{} + for _, endpoint := range endpoints { + epData := map[string]interface{}{ + "id": endpoint.Id, + "type": endpoint.Type, + "tag": endpoint.Tag, + "ext": endpoint.Ext, + } + if endpoint.Options != nil { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(endpoint.Options, &restFields); err != nil { + return nil, err + } + for k, v := range restFields { + epData[k] = v + } + } + data = append(data, epData) + } + return &data, nil +} + +func (o *EndpointService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) { + var endpointsJson []json.RawMessage + var endpoints []*model.Endpoint + err := db.Model(model.Endpoint{}).Scan(&endpoints).Error + if err != nil { + return nil, err + } + for _, endpoint := range endpoints { + endpointJson, err := endpoint.MarshalJSON() + if err != nil { + return nil, err + } + endpointsJson = append(endpointsJson, endpointJson) + } + return endpointsJson, nil +} + +func (s *EndpointService) Save(tx *gorm.DB, act string, data json.RawMessage) error { + var err error + + switch act { + case "new", "edit": + var endpoint model.Endpoint + err = endpoint.UnmarshalJSON(data) + if err != nil { + return err + } + + if endpoint.Type == "warp" { + if act == "new" { + err = s.WarpService.RegisterWarp(&endpoint) + if err != nil { + return err + } + } else { + var old_license string + err = tx.Model(model.Endpoint{}).Select("json_extract(ext, '$.license_key')").Where("id = ?", endpoint.Id).Find(&old_license).Error + if err != nil { + return err + } + err = s.WarpService.SetWarpLicense(old_license, &endpoint) + if err != nil { + return err + } + } + } + + if corePtr.IsRunning() { + configData, err := endpoint.MarshalJSON() + if err != nil { + return err + } + if act == "edit" { + var oldTag string + err = tx.Model(model.Endpoint{}).Select("tag").Where("id = ?", endpoint.Id).Find(&oldTag).Error + if err != nil { + return err + } + err = corePtr.RemoveEndpoint(oldTag) + if err != nil && err != os.ErrInvalid { + return err + } + } + err = corePtr.AddEndpoint(configData) + if err != nil { + return err + } + } + + err = tx.Save(&endpoint).Error + if err != nil { + return err + } + case "del": + var tag string + err = json.Unmarshal(data, &tag) + if err != nil { + return err + } + if corePtr.IsRunning() { + err = corePtr.RemoveEndpoint(tag) + if err != nil && err != os.ErrInvalid { + return err + } + } + err = tx.Where("tag = ?", tag).Delete(model.Endpoint{}).Error + if err != nil { + return err + } + default: + return common.NewErrorf("unknown action: %s", act) + } + return nil +} diff --git a/service/inbounds.go b/service/inbounds.go new file mode 100644 index 0000000..8d7fc14 --- /dev/null +++ b/service/inbounds.go @@ -0,0 +1,362 @@ +package service + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/util" + "github.com/alireza0/s-ui/util/common" + + "gorm.io/gorm" +) + +type InboundService struct { + ClientService +} + +func (s *InboundService) Get(ids string) (*[]map[string]interface{}, error) { + if ids == "" { + return s.GetAll() + } + return s.getById(ids) +} + +func (s *InboundService) getById(ids string) (*[]map[string]interface{}, error) { + var inbound []model.Inbound + var result []map[string]interface{} + db := database.GetDB() + err := db.Model(model.Inbound{}).Where("id in ?", strings.Split(ids, ",")).Scan(&inbound).Error + if err != nil { + return nil, err + } + for _, inb := range inbound { + inbData, err := inb.MarshalFull() + if err != nil { + return nil, err + } + result = append(result, *inbData) + } + return &result, nil +} + +func (s *InboundService) GetAll() (*[]map[string]interface{}, error) { + db := database.GetDB() + inbounds := []model.Inbound{} + err := db.Model(model.Inbound{}).Scan(&inbounds).Error + if err != nil { + return nil, err + } + var data []map[string]interface{} + for _, inbound := range inbounds { + var shadowtls_version uint + ss_managed := false + inbData := map[string]interface{}{ + "id": inbound.Id, + "type": inbound.Type, + "tag": inbound.Tag, + "tls_id": inbound.TlsId, + } + if inbound.Options != nil { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(inbound.Options, &restFields); err != nil { + return nil, err + } + inbData["listen"] = restFields["listen"] + inbData["listen_port"] = restFields["listen_port"] + if inbound.Type == "shadowtls" { + json.Unmarshal(restFields["version"], &shadowtls_version) + } + if inbound.Type == "shadowsocks" { + json.Unmarshal(restFields["managed"], &ss_managed) + } + } + if s.hasUser(inbound.Type) && + !(inbound.Type == "shadowtls" && shadowtls_version < 3) && + !(inbound.Type == "shadowsocks" && ss_managed) { + users := []string{} + err = db.Raw("SELECT clients.name FROM clients, json_each(clients.inbounds) as je WHERE je.value = ?", inbound.Id).Scan(&users).Error + if err != nil { + return nil, err + } + inbData["users"] = users + } + + data = append(data, inbData) + } + return &data, nil +} + +func (s *InboundService) FromIds(ids []uint) ([]*model.Inbound, error) { + db := database.GetDB() + inbounds := []*model.Inbound{} + err := db.Model(model.Inbound{}).Where("id in ?", ids).Scan(&inbounds).Error + if err != nil { + return nil, err + } + return inbounds, nil +} + +func (s *InboundService) Save(tx *gorm.DB, act string, data json.RawMessage, initUserIds string, hostname string) error { + var err error + + switch act { + case "new", "edit": + var inbound model.Inbound + err = inbound.UnmarshalJSON(data) + if err != nil { + return err + } + if inbound.TlsId > 0 { + err = tx.Model(model.Tls{}).Where("id = ?", inbound.TlsId).Find(&inbound.Tls).Error + if err != nil { + return err + } + } + var oldTag string + if act == "edit" { + err = tx.Model(model.Inbound{}).Select("tag").Where("id = ?", inbound.Id).Find(&oldTag).Error + if err != nil { + return err + } + } + + if corePtr.IsRunning() { + if act == "edit" { + err = corePtr.RemoveInbound(oldTag) + if err != nil && err != os.ErrInvalid { + return err + } + } + + inboundConfig, err := inbound.MarshalJSON() + if err != nil { + return err + } + + if act == "edit" { + inboundConfig, err = s.addUsers(tx, inboundConfig, inbound.Id, inbound.Type) + } else { + inboundConfig, err = s.initUsers(tx, inboundConfig, initUserIds, inbound.Type) + } + if err != nil { + return err + } + + err = corePtr.AddInbound(inboundConfig) + if err != nil { + return err + } + } + + err = util.FillOutJson(&inbound, hostname) + if err != nil { + return err + } + + err = tx.Save(&inbound).Error + if err != nil { + return err + } + switch act { + case "new": + err = s.ClientService.UpdateClientsOnInboundAdd(tx, initUserIds, inbound.Id, hostname) + case "edit": + err = s.ClientService.UpdateLinksByInboundChange(tx, &[]model.Inbound{inbound}, hostname, oldTag) + } + if err != nil { + return err + } + case "del": + var tag string + err = json.Unmarshal(data, &tag) + if err != nil { + return err + } + if corePtr.IsRunning() { + err = corePtr.RemoveInbound(tag) + if err != nil && err != os.ErrInvalid { + return err + } + } + var id uint + err = tx.Model(model.Inbound{}).Select("id").Where("tag = ?", tag).Scan(&id).Error + if err != nil { + return err + } + err = s.ClientService.UpdateClientsOnInboundDelete(tx, id, tag) + if err != nil { + return err + } + err = tx.Where("tag = ?", tag).Delete(model.Inbound{}).Error + if err != nil { + return err + } + default: + return common.NewErrorf("unknown action: %s", act) + } + return nil +} + +func (s *InboundService) UpdateOutJsons(tx *gorm.DB, inboundIds []uint, hostname string) error { + var inbounds []model.Inbound + err := tx.Model(model.Inbound{}).Preload("Tls").Where("id in ?", inboundIds).Find(&inbounds).Error + if err != nil { + return err + } + for _, inbound := range inbounds { + err = util.FillOutJson(&inbound, hostname) + if err != nil { + return err + } + err = tx.Model(model.Inbound{}).Where("tag = ?", inbound.Tag).Update("out_json", inbound.OutJson).Error + if err != nil { + return err + } + } + + return nil +} + +func (s *InboundService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) { + var inboundsJson []json.RawMessage + var inbounds []*model.Inbound + err := db.Model(model.Inbound{}).Preload("Tls").Find(&inbounds).Error + if err != nil { + return nil, err + } + for _, inbound := range inbounds { + inboundJson, err := inbound.MarshalJSON() + if err != nil { + return nil, err + } + inboundJson, err = s.addUsers(db, inboundJson, inbound.Id, inbound.Type) + if err != nil { + return nil, err + } + inboundsJson = append(inboundsJson, inboundJson) + } + return inboundsJson, nil +} + +func (s *InboundService) hasUser(inboundType string) bool { + switch inboundType { + case "mixed", "socks", "http", "shadowsocks", "vmess", "trojan", "naive", "hysteria", "shadowtls", "tuic", "hysteria2", "vless", "anytls": + return true + } + return false +} + +func (s *InboundService) fetchUsers(db *gorm.DB, inboundType string, condition string, inbound map[string]interface{}) ([]json.RawMessage, error) { + if inboundType == "shadowtls" { + version, _ := inbound["version"].(float64) + if int(version) < 3 { + return nil, nil + } + } + if inboundType == "shadowsocks" { + method, _ := inbound["method"].(string) + if method == "2022-blake3-aes-128-gcm" { + inboundType = "shadowsocks16" + } + } + + var users []string + + err := db.Raw( + fmt.Sprintf(`SELECT json_extract(clients.config, "$.%s") + FROM clients WHERE enable = true AND %s`, + inboundType, condition)).Scan(&users).Error + if err != nil { + return nil, err + } + var usersJson []json.RawMessage + for _, user := range users { + if inboundType == "vless" && inbound["tls"] == nil { + user = strings.Replace(user, "xtls-rprx-vision", "", -1) + } + usersJson = append(usersJson, json.RawMessage(user)) + } + return usersJson, nil +} + +func (s *InboundService) addUsers(db *gorm.DB, inboundJson []byte, inboundId uint, inboundType string) ([]byte, error) { + if !s.hasUser(inboundType) { + return inboundJson, nil + } + + var inbound map[string]interface{} + err := json.Unmarshal(inboundJson, &inbound) + if err != nil { + return nil, err + } + + condition := fmt.Sprintf("%d IN (SELECT json_each.value FROM json_each(clients.inbounds))", inboundId) + inbound["users"], err = s.fetchUsers(db, inboundType, condition, inbound) + if err != nil { + return nil, err + } + + return json.Marshal(inbound) +} + +func (s *InboundService) initUsers(db *gorm.DB, inboundJson []byte, clientIds string, inboundType string) ([]byte, error) { + ClientIds := strings.Split(clientIds, ",") + if len(ClientIds) == 0 { + return inboundJson, nil + } + + if !s.hasUser(inboundType) { + return inboundJson, nil + } + + var inbound map[string]interface{} + err := json.Unmarshal(inboundJson, &inbound) + if err != nil { + return nil, err + } + + condition := fmt.Sprintf("id IN (%s)", strings.Join(ClientIds, ",")) + inbound["users"], err = s.fetchUsers(db, inboundType, condition, inbound) + if err != nil { + return nil, err + } + + return json.Marshal(inbound) +} + +func (s *InboundService) RestartInbounds(tx *gorm.DB, ids []uint) error { + if !corePtr.IsRunning() { + return nil + } + var inbounds []*model.Inbound + err := tx.Model(model.Inbound{}).Preload("Tls").Where("id in ?", ids).Find(&inbounds).Error + if err != nil { + return err + } + for _, inbound := range inbounds { + err = corePtr.RemoveInbound(inbound.Tag) + if err != nil && err != os.ErrInvalid { + return err + } + // Close all existing connections + corePtr.GetInstance().ConnTracker().CloseConnByInbound(inbound.Tag) + + inboundConfig, err := inbound.MarshalJSON() + if err != nil { + return err + } + inboundConfig, err = s.addUsers(tx, inboundConfig, inbound.Id, inbound.Type) + if err != nil { + return err + } + err = corePtr.AddInbound(inboundConfig) + if err != nil { + return err + } + } + return nil +} diff --git a/service/outbounds.go b/service/outbounds.go new file mode 100644 index 0000000..d587303 --- /dev/null +++ b/service/outbounds.go @@ -0,0 +1,118 @@ +package service + +import ( + "encoding/json" + "os" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/util/common" + + "gorm.io/gorm" +) + +type OutboundService struct{} + +func (o *OutboundService) GetAll() (*[]map[string]interface{}, error) { + db := database.GetDB() + outbounds := []*model.Outbound{} + err := db.Model(model.Outbound{}).Scan(&outbounds).Error + if err != nil { + return nil, err + } + var data []map[string]interface{} + for _, outbound := range outbounds { + outData := map[string]interface{}{ + "id": outbound.Id, + "type": outbound.Type, + "tag": outbound.Tag, + } + if outbound.Options != nil { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(outbound.Options, &restFields); err != nil { + return nil, err + } + for k, v := range restFields { + outData[k] = v + } + } + data = append(data, outData) + } + return &data, nil +} + +func (o *OutboundService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) { + var outboundsJson []json.RawMessage + var outbounds []*model.Outbound + err := db.Model(model.Outbound{}).Scan(&outbounds).Error + if err != nil { + return nil, err + } + for _, outbound := range outbounds { + outboundJson, err := outbound.MarshalJSON() + if err != nil { + return nil, err + } + outboundsJson = append(outboundsJson, outboundJson) + } + return outboundsJson, nil +} + +func (s *OutboundService) Save(tx *gorm.DB, act string, data json.RawMessage) error { + var err error + + switch act { + case "new", "edit": + var outbound model.Outbound + err = outbound.UnmarshalJSON(data) + if err != nil { + return err + } + + if corePtr.IsRunning() { + configData, err := outbound.MarshalJSON() + if err != nil { + return err + } + if act == "edit" { + var oldTag string + err = tx.Model(model.Outbound{}).Select("tag").Where("id = ?", outbound.Id).Find(&oldTag).Error + if err != nil { + return err + } + err = corePtr.RemoveOutbound(oldTag) + if err != nil && err != os.ErrInvalid { + return err + } + } + err = corePtr.AddOutbound(configData) + if err != nil { + return err + } + } + + err = tx.Save(&outbound).Error + if err != nil { + return err + } + case "del": + var tag string + err = json.Unmarshal(data, &tag) + if err != nil { + return err + } + if corePtr.IsRunning() { + err = corePtr.RemoveOutbound(tag) + if err != nil && err != os.ErrInvalid { + return err + } + } + err = tx.Where("tag = ?", tag).Delete(model.Outbound{}).Error + if err != nil { + return err + } + default: + return common.NewErrorf("unknown action: %s", act) + } + return nil +} diff --git a/service/panel.go b/service/panel.go new file mode 100644 index 0000000..24e1c2f --- /dev/null +++ b/service/panel.go @@ -0,0 +1,32 @@ +package service + +import ( + "os" + "runtime" + "syscall" + "time" + + "github.com/alireza0/s-ui/logger" +) + +type PanelService struct { +} + +func (s *PanelService) RestartPanel(delay time.Duration) error { + p, err := os.FindProcess(syscall.Getpid()) + if err != nil { + return err + } + go func() { + time.Sleep(delay) + if runtime.GOOS == "windows" { + err = p.Kill() + } else { + err = p.Signal(syscall.SIGHUP) + } + if err != nil { + logger.Error("send signal SIGHUP failed:", err) + } + }() + return nil +} diff --git a/service/server.go b/service/server.go new file mode 100644 index 0000000..8db4112 --- /dev/null +++ b/service/server.go @@ -0,0 +1,283 @@ +package service + +import ( + "encoding/base64" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + + "github.com/sagernet/sing-box/common/tls" + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/host" + "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/net" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +type ServerService struct{} + +func (s *ServerService) GetStatus(request string) *map[string]interface{} { + status := make(map[string]interface{}, 0) + requests := strings.Split(request, ",") + for _, req := range requests { + switch req { + case "cpu": + status["cpu"] = s.GetCpuPercent() + case "mem": + status["mem"] = s.GetMemInfo() + case "dsk": + status["dsk"] = s.GetDiskInfo() + case "dio": + status["dio"] = s.GetDiskIO() + case "swp": + status["swp"] = s.GetSwapInfo() + case "net": + status["net"] = s.GetNetInfo() + case "sys": + status["sys"] = s.GetSystemInfo() + case "sbd": + status["sbd"] = s.GetSingboxInfo() + case "db": + status["db"] = s.GetDatabaseInfo() + } + } + return &status +} + +func (s *ServerService) GetCpuPercent() float64 { + percents, err := cpu.Percent(0, false) + if err != nil { + logger.Warning("get cpu percent failed:", err) + return 0 + } else { + return percents[0] + } +} + +func (s *ServerService) GetMemInfo() map[string]interface{} { + info := make(map[string]interface{}, 0) + memInfo, err := mem.VirtualMemory() + if err != nil { + logger.Warning("get virtual memory failed:", err) + } else { + info["current"] = memInfo.Used + info["total"] = memInfo.Total + } + return info +} + +func (s *ServerService) GetDiskInfo() map[string]interface{} { + info := make(map[string]interface{}, 0) + diskInfo, err := disk.Usage("/") + if err != nil { + logger.Warning("get disk usage failed:", err) + } else { + info["current"] = diskInfo.Used + info["total"] = diskInfo.Total + } + return info +} + +func (s *ServerService) GetDiskIO() map[string]interface{} { + info := make(map[string]interface{}, 0) + ioStats, err := disk.IOCounters() + if err != nil { + logger.Warning("get disk io counters failed:", err) + } else if len(ioStats) > 0 { + infoR, infoW := uint64(0), uint64(0) + for _, ioStat := range ioStats { + infoR += ioStat.ReadBytes + infoW += ioStat.WriteBytes + } + info["read"] = infoR + info["write"] = infoW + } else { + logger.Warning("can not find disk io counters") + } + return info +} + +func (s *ServerService) GetSwapInfo() map[string]interface{} { + info := make(map[string]interface{}, 0) + swapInfo, err := mem.SwapMemory() + if err != nil { + logger.Warning("get swap memory failed:", err) + } else { + info["current"] = swapInfo.Used + info["total"] = swapInfo.Total + } + return info +} + +func (s *ServerService) GetNetInfo() map[string]interface{} { + info := make(map[string]interface{}, 0) + ioStats, err := net.IOCounters(false) + if err != nil { + logger.Warning("get io counters failed:", err) + } else if len(ioStats) > 0 { + ioStat := ioStats[0] + info["sent"] = ioStat.BytesSent + info["recv"] = ioStat.BytesRecv + info["psent"] = ioStat.PacketsSent + info["precv"] = ioStat.PacketsRecv + } else { + logger.Warning("can not find io counters") + } + return info +} + +func (s *ServerService) GetSingboxInfo() map[string]interface{} { + var rtm runtime.MemStats + runtime.ReadMemStats(&rtm) + isRunning := corePtr.IsRunning() + uptime := uint32(0) + if isRunning { + uptime = corePtr.GetInstance().Uptime() + } + return map[string]interface{}{ + "running": isRunning, + "stats": map[string]interface{}{ + "NumGoroutine": uint32(runtime.NumGoroutine()), + "Alloc": rtm.Alloc, + "Uptime": uptime, + }, + } +} + +func (s *ServerService) GetSystemInfo() map[string]interface{} { + info := make(map[string]interface{}, 0) + var rtm runtime.MemStats + runtime.ReadMemStats(&rtm) + + info["appMem"] = rtm.Sys + info["appThreads"] = uint32(runtime.NumGoroutine()) + cpuInfo, err := cpu.Info() + if err == nil { + info["cpuType"] = cpuInfo[0].ModelName + } + info["cpuCount"] = runtime.NumCPU() + info["hostName"], _ = os.Hostname() + info["appVersion"] = config.GetVersion() + ipv4 := make([]string, 0) + ipv6 := make([]string, 0) + // get ip address + netInterfaces, _ := net.Interfaces() + for i := 0; i < len(netInterfaces); i++ { + if len(netInterfaces[i].Flags) > 2 && netInterfaces[i].Flags[0] == "up" && netInterfaces[i].Flags[1] != "loopback" { + addrs := netInterfaces[i].Addrs + + for _, address := range addrs { + if strings.Contains(address.Addr, ".") { + ipv4 = append(ipv4, address.Addr) + } else if address.Addr[0:6] != "fe80::" { + ipv6 = append(ipv6, address.Addr) + } + } + } + } + info["ipv4"] = ipv4 + info["ipv6"] = ipv6 + info["bootTime"], _ = host.BootTime() + + return info +} + +func (s *ServerService) GetLogs(count string, level string) []string { + c, err := strconv.Atoi(count) + if err != nil { + c = 10 + } + return logger.GetLogs(c, level) +} + +func (s *ServerService) GenKeypair(keyType string, options string) []string { + if len(keyType) == 0 { + return []string{"No keypair to generate"} + } + + switch keyType { + case "ech": + return s.generateECHKeyPair(options) + case "tls": + return s.generateTLSKeyPair(options) + case "reality": + return s.generateRealityKeyPair() + case "wireguard": + return s.generateWireGuardKey(options) + } + + return []string{"Failed to generate keypair"} +} + +func (s *ServerService) generateECHKeyPair(serverName string) []string { + configPem, keyPem, err := tls.ECHKeygenDefault(serverName) + if err != nil { + return []string{"Failed to generate ECH keypair: ", err.Error()} + } + return append(strings.Split(configPem, "\n"), strings.Split(keyPem, "\n")...) +} + +func (s *ServerService) generateTLSKeyPair(serverName string) []string { + privateKeyPem, publicKeyPem, err := tls.GenerateCertificate(nil, nil, time.Now, serverName, time.Now().AddDate(0, 12, 0)) + if err != nil { + return []string{"Failed to generate TLS keypair: ", err.Error()} + } + return append(strings.Split(string(privateKeyPem), "\n"), strings.Split(string(publicKeyPem), "\n")...) +} + +func (s *ServerService) generateRealityKeyPair() []string { + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return []string{"Failed to generate Reality keypair: ", err.Error()} + } + publicKey := privateKey.PublicKey() + return []string{"PrivateKey: " + base64.RawURLEncoding.EncodeToString(privateKey[:]), "PublicKey: " + base64.RawURLEncoding.EncodeToString(publicKey[:])} +} + +func (s *ServerService) generateWireGuardKey(pk string) []string { + if len(pk) > 0 { + key, _ := wgtypes.ParseKey(pk) + return []string{key.PublicKey().String()} + } + wgKeys, err := wgtypes.GeneratePrivateKey() + if err != nil { + return []string{"Failed to generate wireguard keypair: ", err.Error()} + } + return []string{"PrivateKey: " + wgKeys.String(), "PublicKey: " + wgKeys.PublicKey().String()} +} + +func (s *ServerService) GetDatabaseInfo() map[string]int64 { + info := make(map[string]int64, 0) + db := database.GetDB() + if db == nil { + return nil + } + + var clientsCount, inboundsCount, outboundsCount, servicesCount, endpointsCount, clientUp, clientDown int64 + + db.Model(&model.Client{}).Count(&clientsCount) + db.Model(&model.Inbound{}).Count(&inboundsCount) + db.Model(&model.Outbound{}).Count(&outboundsCount) + db.Model(&model.Service{}).Count(&servicesCount) + db.Model(&model.Endpoint{}).Count(&endpointsCount) + db.Model(&model.Client{}).Select("COALESCE(SUM(up+total_up),0)").Scan(&clientUp) + db.Model(&model.Client{}).Select("COALESCE(SUM(down+total_down),0)").Scan(&clientDown) + + info["clients"] = clientsCount + info["inbounds"] = inboundsCount + info["outbounds"] = outboundsCount + info["services"] = servicesCount + info["endpoints"] = endpointsCount + info["clientUp"] = clientUp + info["clientDown"] = clientDown + + return info +} diff --git a/service/services.go b/service/services.go new file mode 100644 index 0000000..5e8272c --- /dev/null +++ b/service/services.go @@ -0,0 +1,153 @@ +package service + +import ( + "encoding/json" + "os" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/util/common" + + "gorm.io/gorm" +) + +type ServicesService struct{} + +func (s *ServicesService) GetAll() (*[]map[string]interface{}, error) { + db := database.GetDB() + services := []model.Service{} + err := db.Model(model.Service{}).Scan(&services).Error + if err != nil { + return nil, err + } + var data []map[string]interface{} + for _, srv := range services { + srvData := map[string]interface{}{ + "id": srv.Id, + "type": srv.Type, + "tag": srv.Tag, + "tls_id": srv.TlsId, + } + if srv.Options != nil { + var restFields map[string]json.RawMessage + if err := json.Unmarshal(srv.Options, &restFields); err != nil { + return nil, err + } + for k, v := range restFields { + srvData[k] = v + } + } + + data = append(data, srvData) + } + return &data, nil +} + +func (s *ServicesService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) { + var servicesJson []json.RawMessage + var services []*model.Service + err := db.Model(model.Service{}).Preload("Tls").Find(&services).Error + if err != nil { + return nil, err + } + for _, srv := range services { + srvJson, err := srv.MarshalJSON() + if err != nil { + return nil, err + } + servicesJson = append(servicesJson, srvJson) + } + return servicesJson, nil +} + +func (s *ServicesService) Save(tx *gorm.DB, act string, data json.RawMessage) error { + var err error + + switch act { + case "new", "edit": + var srv model.Service + err = srv.UnmarshalJSON(data) + if err != nil { + return err + } + + if srv.TlsId > 0 { + err = tx.Model(model.Tls{}).Where("id = ?", srv.TlsId).Find(&srv.Tls).Error + if err != nil { + return err + } + } + + if corePtr.IsRunning() { + configData, err := srv.MarshalJSON() + if err != nil { + return err + } + if act == "edit" { + var oldTag string + err = tx.Model(model.Service{}).Select("tag").Where("id = ?", srv.Id).Find(&oldTag).Error + if err != nil { + return err + } + err = corePtr.RemoveService(oldTag) + if err != nil && err != os.ErrInvalid { + return err + } + } + err = corePtr.AddService(configData) + if err != nil { + return err + } + } + + err = tx.Save(&srv).Error + if err != nil { + return err + } + case "del": + var tag string + err = json.Unmarshal(data, &tag) + if err != nil { + return err + } + if corePtr.IsRunning() { + err = corePtr.RemoveService(tag) + if err != nil && err != os.ErrInvalid { + return err + } + } + err = tx.Where("tag = ?", tag).Delete(model.Service{}).Error + if err != nil { + return err + } + default: + return common.NewErrorf("unknown action: %s", act) + } + return nil +} + +func (s *ServicesService) RestartServices(tx *gorm.DB, ids []uint) error { + if !corePtr.IsRunning() { + return nil + } + var services []*model.Service + err := tx.Model(model.Service{}).Preload("Tls").Where("id in ?", ids).Find(&services).Error + if err != nil { + return err + } + for _, srv := range services { + err = corePtr.RemoveService(srv.Tag) + if err != nil && err != os.ErrInvalid { + return err + } + srvConfig, err := srv.MarshalJSON() + if err != nil { + return err + } + err = corePtr.AddService(srvConfig) + if err != nil { + return err + } + } + return nil +} diff --git a/service/setting.go b/service/setting.go new file mode 100644 index 0000000..2ff8033 --- /dev/null +++ b/service/setting.go @@ -0,0 +1,421 @@ +package service + +import ( + "encoding/json" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" + + "gorm.io/gorm" +) + +var defaultConfig = `{ + "log": { + "level": "info" + }, + "dns": { + "servers": [], + "rules": [] + }, + "route": { + "rules": [ + { + "action": "sniff" + }, + { + "protocol": [ + "dns" + ], + "action": "hijack-dns" + } + ] + }, + "experimental": {} +}` + +var defaultValueMap = map[string]string{ + "webListen": "", + "webDomain": "", + "webPort": "2095", + "secret": common.Random(32), + "webCertFile": "", + "webKeyFile": "", + "webPath": "/app/", + "webURI": "", + "sessionMaxAge": "0", + "trafficAge": "30", + "timeLocation": "Asia/Tehran", + "subListen": "", + "subPort": "2096", + "subPath": "/sub/", + "subDomain": "", + "subCertFile": "", + "subKeyFile": "", + "subUpdates": "12", + "subEncode": "true", + "subShowInfo": "false", + "subURI": "", + "subJsonExt": "", + "subClashExt": "", + "config": defaultConfig, + "version": config.GetVersion(), +} + +type SettingService struct { +} + +func (s *SettingService) GetAllSetting() (*map[string]string, error) { + db := database.GetDB() + settings := make([]*model.Setting, 0) + err := db.Model(model.Setting{}).Find(&settings).Error + if err != nil { + return nil, err + } + allSetting := map[string]string{} + + for _, setting := range settings { + allSetting[setting.Key] = setting.Value + } + + for key, defaultValue := range defaultValueMap { + if _, exists := allSetting[key]; !exists { + err = s.saveSetting(key, defaultValue) + if err != nil { + return nil, err + } + allSetting[key] = defaultValue + } + } + + // Due to security principles + delete(allSetting, "secret") + delete(allSetting, "config") + delete(allSetting, "version") + + return &allSetting, nil +} + +func (s *SettingService) ResetSettings() error { + db := database.GetDB() + return db.Where("1 = 1").Delete(model.Setting{}).Error +} + +func (s *SettingService) getSetting(key string) (*model.Setting, error) { + db := database.GetDB() + setting := &model.Setting{} + err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error + if err != nil { + return nil, err + } + return setting, nil +} + +func (s *SettingService) getString(key string) (string, error) { + setting, err := s.getSetting(key) + if database.IsNotFound(err) { + value, ok := defaultValueMap[key] + if !ok { + return "", common.NewErrorf("key <%v> not in defaultValueMap", key) + } + return value, nil + } else if err != nil { + return "", err + } + return setting.Value, nil +} + +func (s *SettingService) saveSetting(key string, value string) error { + setting, err := s.getSetting(key) + db := database.GetDB() + if database.IsNotFound(err) { + return db.Create(&model.Setting{ + Key: key, + Value: value, + }).Error + } else if err != nil { + return err + } + setting.Key = key + setting.Value = value + return db.Save(setting).Error +} + +func (s *SettingService) setString(key string, value string) error { + return s.saveSetting(key, value) +} + +func (s *SettingService) getBool(key string) (bool, error) { + str, err := s.getString(key) + if err != nil { + return false, err + } + return strconv.ParseBool(str) +} + +// func (s *SettingService) setBool(key string, value bool) error { +// return s.setString(key, strconv.FormatBool(value)) +// } + +func (s *SettingService) getInt(key string) (int, error) { + str, err := s.getString(key) + if err != nil { + return 0, err + } + return strconv.Atoi(str) +} + +func (s *SettingService) setInt(key string, value int) error { + return s.setString(key, strconv.Itoa(value)) +} +func (s *SettingService) GetListen() (string, error) { + return s.getString("webListen") +} + +func (s *SettingService) GetWebDomain() (string, error) { + return s.getString("webDomain") +} + +func (s *SettingService) GetPort() (int, error) { + return s.getInt("webPort") +} + +func (s *SettingService) SetPort(port int) error { + return s.setInt("webPort", port) +} + +func (s *SettingService) GetCertFile() (string, error) { + return s.getString("webCertFile") +} + +func (s *SettingService) GetKeyFile() (string, error) { + return s.getString("webKeyFile") +} + +func (s *SettingService) GetWebPath() (string, error) { + webPath, err := s.getString("webPath") + if err != nil { + return "", err + } + if !strings.HasPrefix(webPath, "/") { + webPath = "/" + webPath + } + if !strings.HasSuffix(webPath, "/") { + webPath += "/" + } + return webPath, nil +} + +func (s *SettingService) SetWebPath(webPath string) error { + if !strings.HasPrefix(webPath, "/") { + webPath = "/" + webPath + } + if !strings.HasSuffix(webPath, "/") { + webPath += "/" + } + return s.setString("webPath", webPath) +} + +func (s *SettingService) GetSecret() ([]byte, error) { + secret, err := s.getString("secret") + if secret == defaultValueMap["secret"] { + err := s.saveSetting("secret", secret) + if err != nil { + logger.Warning("save secret failed:", err) + } + } + return []byte(secret), err +} + +func (s *SettingService) GetSessionMaxAge() (int, error) { + return s.getInt("sessionMaxAge") +} + +func (s *SettingService) GetTrafficAge() (int, error) { + return s.getInt("trafficAge") +} + +func (s *SettingService) GetTimeLocation() (*time.Location, error) { + l, err := s.getString("timeLocation") + if err != nil { + return nil, err + } + if runtime.GOOS == "windows" { + l = "Local" + } + location, err := time.LoadLocation(l) + if err != nil { + defaultLocation := defaultValueMap["timeLocation"] + logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation) + return time.LoadLocation(defaultLocation) + } + return location, nil +} + +func (s *SettingService) GetSubListen() (string, error) { + return s.getString("subListen") +} + +func (s *SettingService) GetSubPort() (int, error) { + return s.getInt("subPort") +} + +func (s *SettingService) SetSubPort(subPort int) error { + return s.setInt("subPort", subPort) +} + +func (s *SettingService) GetSubPath() (string, error) { + subPath, err := s.getString("subPath") + if err != nil { + return "", err + } + if !strings.HasPrefix(subPath, "/") { + subPath = "/" + subPath + } + if !strings.HasSuffix(subPath, "/") { + subPath += "/" + } + return subPath, nil +} + +func (s *SettingService) SetSubPath(subPath string) error { + if !strings.HasPrefix(subPath, "/") { + subPath = "/" + subPath + } + if !strings.HasSuffix(subPath, "/") { + subPath += "/" + } + return s.setString("subPath", subPath) +} + +func (s *SettingService) GetSubDomain() (string, error) { + return s.getString("subDomain") +} + +func (s *SettingService) GetSubCertFile() (string, error) { + return s.getString("subCertFile") +} + +func (s *SettingService) GetSubKeyFile() (string, error) { + return s.getString("subKeyFile") +} + +func (s *SettingService) GetSubUpdates() (int, error) { + return s.getInt("subUpdates") +} + +func (s *SettingService) GetSubEncode() (bool, error) { + return s.getBool("subEncode") +} + +func (s *SettingService) GetSubShowInfo() (bool, error) { + return s.getBool("subShowInfo") +} + +func (s *SettingService) GetSubURI() (string, error) { + return s.getString("subURI") +} + +func (s *SettingService) GetFinalSubURI(host string) (string, error) { + allSetting, err := s.GetAllSetting() + if err != nil { + return "", err + } + SubURI := (*allSetting)["subURI"] + if SubURI != "" { + return SubURI, nil + } + protocol := "http" + if (*allSetting)["subKeyFile"] != "" && (*allSetting)["subCertFile"] != "" { + protocol = "https" + } + if (*allSetting)["subDomain"] != "" { + host = (*allSetting)["subDomain"] + } + port := ":" + (*allSetting)["subPort"] + if (port == "80" && protocol == "http") || (port == "443" && protocol == "https") { + port = "" + } + return protocol + "://" + host + port + (*allSetting)["subPath"], nil +} + +func (s *SettingService) GetConfig() (string, error) { + return s.getString("config") +} + +func (s *SettingService) SetConfig(config string) error { + return s.setString("config", config) +} + +func (s *SettingService) SaveConfig(tx *gorm.DB, config json.RawMessage) error { + configs, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return tx.Model(model.Setting{}).Where("key = ?", "config").Update("value", string(configs)).Error +} + +func (s *SettingService) Save(tx *gorm.DB, data json.RawMessage) error { + var err error + var settings map[string]string + err = json.Unmarshal(data, &settings) + if err != nil { + return err + } + for key, obj := range settings { + // Secure file existence check + if obj != "" && (key == "webCertFile" || + key == "webKeyFile" || + key == "subCertFile" || + key == "subKeyFile") { + err = s.fileExists(obj) + if err != nil { + return common.NewError(" -> ", obj, " is not exists") + } + } + + // Correct Pathes start and ends with `/` + if key == "webPath" || + key == "subPath" { + if !strings.HasPrefix(obj, "/") { + obj = "/" + obj + } + if !strings.HasSuffix(obj, "/") { + obj += "/" + } + } + + // Delete all stats if it is set to 0 + if key == "trafficAge" && obj == "0" { + err = tx.Where("id > 0").Delete(model.Stats{}).Error + if err != nil { + return err + } + } + err = tx.Model(model.Setting{}).Where("key = ?", key).Update("value", obj).Error + if err != nil { + return err + } + } + return err +} + +func (s *SettingService) GetSubJsonExt() (string, error) { + return s.getString("subJsonExt") +} + +func (s *SettingService) GetSubClashExt() (string, error) { + return s.getString("subClashExt") +} + +func (s *SettingService) fileExists(path string) error { + _, err := os.Stat(path) + return err +} diff --git a/service/stats.go b/service/stats.go new file mode 100644 index 0000000..6234b97 --- /dev/null +++ b/service/stats.go @@ -0,0 +1,163 @@ +package service + +import ( + "sort" + "time" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + + "gorm.io/gorm" +) + +type onlines struct { + Inbound []string `json:"inbound,omitempty"` + User []string `json:"user,omitempty"` + Outbound []string `json:"outbound,omitempty"` +} + +var onlineResources = &onlines{} + +type StatsService struct { +} + +func (s *StatsService) SaveStats(enableTraffic bool) error { + if corePtr == nil || !corePtr.IsRunning() { + return nil + } + box := corePtr.GetInstance() + if box == nil { + return nil + } + st := box.StatsTracker() + if st == nil { + return nil + } + stats := st.GetStats() + + // Reset onlines + onlineResources.Inbound = nil + onlineResources.Outbound = nil + onlineResources.User = nil + + if len(*stats) == 0 { + return nil + } + + var err error + db := database.GetDB() + tx := db.Begin() + defer func() { + if err == nil { + tx.Commit() + } else { + tx.Rollback() + } + }() + + for _, stat := range *stats { + if stat.Resource == "user" { + if stat.Direction { + err = tx.Model(model.Client{}).Where("name = ?", stat.Tag). + UpdateColumn("up", gorm.Expr("up + ?", stat.Traffic)).Error + } else { + err = tx.Model(model.Client{}).Where("name = ?", stat.Tag). + UpdateColumn("down", gorm.Expr("down + ?", stat.Traffic)).Error + } + if err != nil { + return err + } + } + if stat.Direction { + switch stat.Resource { + case "inbound": + onlineResources.Inbound = append(onlineResources.Inbound, stat.Tag) + case "outbound": + onlineResources.Outbound = append(onlineResources.Outbound, stat.Tag) + case "user": + onlineResources.User = append(onlineResources.User, stat.Tag) + } + } + } + + if !enableTraffic { + return nil + } + err = tx.Create(&stats).Error + return err +} + +func (s *StatsService) GetStats(resource string, tag string, limit int) ([]model.Stats, error) { + var err error + var result []model.Stats + + currentTime := time.Now().Unix() + timeDiff := currentTime - (int64(limit) * 3600) + + db := database.GetDB() + resources := []string{resource} + if resource == "endpoint" { + resources = []string{"inbound", "outbound"} + } + err = db.Model(model.Stats{}).Where("resource in ? AND tag = ? AND date_time > ?", resources, tag, timeDiff).Scan(&result).Error + if err != nil { + return nil, err + } + + result = s.downsampleStats(result, 60) // 60 rows for 30 buckets + return result, nil +} + +// downsampleStats reduces stats to maxRows rows. +// Each bucket outputs two rows (direction false and true) with average Traffic. +func (s *StatsService) downsampleStats(stats []model.Stats, maxRows int) []model.Stats { + if len(stats) <= maxRows { + return stats + } + numBuckets := int(maxRows / 2) + sort.Slice(stats, func(i, j int) bool { return stats[i].DateTime < stats[j].DateTime }) + timeMin, timeMax := stats[0].DateTime, stats[len(stats)-1].DateTime + bucketSpan := (timeMax - timeMin) / int64(numBuckets) + if bucketSpan == 0 { + bucketSpan = 1 + } + downsampled := make([]model.Stats, 0, maxRows) + for i := 0; i < numBuckets; i++ { + bucketStart := timeMin + int64(i)*bucketSpan + bucketEnd := timeMin + int64(i+1)*bucketSpan + if i == numBuckets-1 { + bucketEnd = timeMax + 1 + } + for _, dir := range []bool{false, true} { + var sum int64 + var count int + for _, r := range stats { + if r.DateTime >= bucketStart && r.DateTime < bucketEnd && r.Direction == dir { + sum += r.Traffic + count++ + } + } + avg := int64(0) + if count > 0 { + avg = sum / int64(count) + } + downsampled = append(downsampled, model.Stats{ + DateTime: bucketStart, + Resource: stats[0].Resource, + Tag: stats[0].Tag, + Direction: dir, + Traffic: avg, + }) + } + } + return downsampled +} + +func (s *StatsService) GetOnlines() (onlines, error) { + return *onlineResources, nil +} +func (s *StatsService) DelOldStats(days int) error { + oldTime := time.Now().AddDate(0, 0, -(days)).Unix() + db := database.GetDB() + return db.Where("date_time < ?", oldTime).Delete(model.Stats{}).Error +} diff --git a/service/tls.go b/service/tls.go new file mode 100644 index 0000000..d9c9e8b --- /dev/null +++ b/service/tls.go @@ -0,0 +1,105 @@ +package service + +import ( + "encoding/json" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/util/common" + + "gorm.io/gorm" +) + +type TlsService struct { + InboundService + ServicesService +} + +func (s *TlsService) GetAll() ([]model.Tls, error) { + db := database.GetDB() + tlsConfig := []model.Tls{} + err := db.Model(model.Tls{}).Scan(&tlsConfig).Error + if err != nil { + return nil, err + } + + return tlsConfig, nil +} + +func (s *TlsService) Save(tx *gorm.DB, action string, data json.RawMessage, hostname string) error { + var err error + + switch action { + case "new", "edit": + var tls model.Tls + err = json.Unmarshal(data, &tls) + if err != nil { + return err + } + err = tx.Save(&tls).Error + if err != nil { + return err + } + if action == "edit" { + var inbounds []model.Inbound + err = tx.Model(model.Inbound{}).Preload("Tls").Where("tls_id = ?", tls.Id).Find(&inbounds).Error + if err != nil { + return err + } + if len(inbounds) > 0 { + err = s.ClientService.UpdateLinksByInboundChange(tx, &inbounds, hostname, "") + if err != nil { + return err + } + var inboundIds []uint + for _, inbound := range inbounds { + inboundIds = append(inboundIds, inbound.Id) + } + err = s.InboundService.UpdateOutJsons(tx, inboundIds, hostname) + if err != nil { + return common.NewError("unable to update out_json of inbounds: ", err.Error()) + } + err = s.InboundService.RestartInbounds(tx, inboundIds) + if err != nil { + return err + } + } + var serviceIds []uint + err = tx.Model(model.Service{}).Where("tls_id = ?", tls.Id).Scan(&serviceIds).Error + if err != nil { + return err + } + if len(serviceIds) > 0 { + err = s.ServicesService.RestartServices(tx, serviceIds) + if err != nil { + return err + } + } + } + case "del": + var id uint + err = json.Unmarshal(data, &id) + if err != nil { + return err + } + var inboundCount int64 + err = tx.Model(model.Inbound{}).Where("tls_id = ?", id).Count(&inboundCount).Error + if err != nil { + return err + } + var serviceCount int64 + err = tx.Model(model.Service{}).Where("tls_id = ?", id).Count(&serviceCount).Error + if err != nil { + return err + } + if inboundCount > 0 || serviceCount > 0 { + return common.NewError("tls in use") + } + err = tx.Where("id = ?", id).Delete(model.Tls{}).Error + if err != nil { + return err + } + } + + return nil +} diff --git a/service/user.go b/service/user.go new file mode 100644 index 0000000..aea2861 --- /dev/null +++ b/service/user.go @@ -0,0 +1,161 @@ +package service + +import ( + "encoding/json" + "time" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" +) + +type UserService struct { +} + +func (s *UserService) GetFirstUser() (*model.User, error) { + db := database.GetDB() + + user := &model.User{} + err := db.Model(model.User{}). + First(user). + Error + if err != nil { + return nil, err + } + return user, nil +} + +func (s *UserService) UpdateFirstUser(username string, password string) error { + if username == "" { + return common.NewError("username can not be empty") + } else if password == "" { + return common.NewError("password can not be empty") + } + db := database.GetDB() + user := &model.User{} + err := db.Model(model.User{}).First(user).Error + if database.IsNotFound(err) { + user.Username = username + user.Password = password + return db.Model(model.User{}).Create(user).Error + } else if err != nil { + return err + } + user.Username = username + user.Password = password + return db.Save(user).Error +} + +func (s *UserService) Login(username string, password string, remoteIP string) (string, error) { + user := s.CheckUser(username, password, remoteIP) + if user == nil { + return "", common.NewError("wrong user or password! IP: ", remoteIP) + } + return user.Username, nil +} + +func (s *UserService) CheckUser(username string, password string, remoteIP string) *model.User { + db := database.GetDB() + + user := &model.User{} + err := db.Model(model.User{}). + Where("username = ? and password = ?", username, password). + First(user). + Error + if database.IsNotFound(err) { + return nil + } else if err != nil { + logger.Warning("check user err:", err, " IP: ", remoteIP) + return nil + } + + lastLoginTxt := time.Now().Format("2006-01-02 15:04:05") + " " + remoteIP + err = db.Model(model.User{}). + Where("username = ?", username). + Update("last_logins", &lastLoginTxt).Error + if err != nil { + logger.Warning("unable to log login data", err) + } + return user +} + +func (s *UserService) GetUsers() (*[]model.User, error) { + var users []model.User + db := database.GetDB() + err := db.Model(model.User{}).Select("id,username,last_logins").Scan(&users).Error + if err != nil { + return nil, err + } + return &users, nil +} + +func (s *UserService) ChangePass(id string, oldPass string, newUser string, newPass string) error { + db := database.GetDB() + user := &model.User{} + err := db.Model(model.User{}).Where("id = ? AND password = ?", id, oldPass).First(user).Error + if err != nil || database.IsNotFound(err) { + return err + } + user.Username = newUser + user.Password = newPass + return db.Save(user).Error +} + +func (s *UserService) LoadTokens() ([]byte, error) { + db := database.GetDB() + var tokens []model.Tokens + err := db.Model(model.Tokens{}).Preload("User").Where("expiry == 0 or expiry > ?", time.Now().Unix()).Find(&tokens).Error + if err != nil { + return nil, err + } + var result []map[string]interface{} + for _, t := range tokens { + result = append(result, map[string]interface{}{ + "token": t.Token, + "expiry": t.Expiry, + "username": t.User.Username, + }) + } + jsonResult, _ := json.MarshalIndent(result, "", " ") + return jsonResult, nil +} + +func (s *UserService) GetUserTokens(username string) (*[]model.Tokens, error) { + db := database.GetDB() + var token []model.Tokens + err := db.Model(model.Tokens{}).Select("id,desc,'****' as token,expiry,user_id").Where("user_id = (select id from users where username = ?)", username).Find(&token).Error + if err != nil && !database.IsNotFound(err) { + println(err.Error()) + return nil, err + } + return &token, nil +} + +func (s *UserService) AddToken(username string, expiry int64, desc string) (string, error) { + db := database.GetDB() + var userId uint + err := db.Model(model.User{}).Where("username = ?", username).Select("id").Scan(&userId).Error + if err != nil { + return "", err + } + if expiry > 0 { + expiry = expiry*86400 + time.Now().Unix() + } + token := &model.Tokens{ + Token: common.Random(32), + Desc: desc, + Expiry: expiry, + UserId: userId, + } + err = db.Create(token).Error + if err != nil { + return "", err + } + return token.Token, nil +} + +func (s *UserService) DeleteToken(id string) error { + db := database.GetDB() + return db.Model(model.Tokens{}).Where("id = ?", id).Delete(&model.Tokens{}).Error +} diff --git a/service/warp.go b/service/warp.go new file mode 100644 index 0000000..89bfb3b --- /dev/null +++ b/service/warp.go @@ -0,0 +1,224 @@ +package service + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strconv" + "time" + + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +type WarpService struct{} + +func (s *WarpService) getWarpInfo(deviceId string, accessToken string) ([]byte, error) { + url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s", deviceId) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil || resp.StatusCode != 200 { + return nil, err + } + defer resp.Body.Close() + buffer := bytes.NewBuffer(make([]byte, 8192)) + buffer.Reset() + _, err = buffer.ReadFrom(resp.Body) + if err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +func (s *WarpService) RegisterWarp(ep *model.Endpoint) error { + tos := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + privateKey, _ := wgtypes.GenerateKey() + publicKey := privateKey.PublicKey().String() + hostName, _ := os.Hostname() + + data := fmt.Sprintf(`{"key":"%s","tos":"%s","type": "PC","model": "s-ui", "name": "%s"}`, publicKey, tos, hostName) + url := "https://api.cloudflareclient.com/v0a2158/reg" + + req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(data))) + if err != nil { + return err + } + + req.Header.Add("CF-Client-Version", "a-7.21-0721") + req.Header.Add("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil || resp.StatusCode != 200 { + return err + } + defer resp.Body.Close() + buffer := bytes.NewBuffer(make([]byte, 8192)) + buffer.Reset() + _, err = buffer.ReadFrom(resp.Body) + if err != nil { + return err + } + + var rspData map[string]interface{} + err = json.Unmarshal(buffer.Bytes(), &rspData) + if err != nil { + return err + } + + deviceId := rspData["id"].(string) + token := rspData["token"].(string) + license, ok := rspData["account"].(map[string]interface{})["license"].(string) + if !ok { + logger.Debug("Error accessing license value.") + return err + } + + warpInfo, err := s.getWarpInfo(deviceId, token) + if err != nil { + return err + } + + var warpDetails map[string]interface{} + err = json.Unmarshal(warpInfo, &warpDetails) + if err != nil { + return err + } + + warpConfig, _ := warpDetails["config"].(map[string]interface{}) + clientId, _ := warpConfig["client_id"].(string) + reserved := s.getReserved(clientId) + interfaceConfig, _ := warpConfig["interface"].(map[string]interface{}) + addresses, _ := interfaceConfig["addresses"].(map[string]interface{}) + v4, _ := addresses["v4"].(string) + v6, _ := addresses["v6"].(string) + peer, _ := warpConfig["peers"].([]interface{})[0].(map[string]interface{}) + peerEndpoint, _ := peer["endpoint"].(map[string]interface{})["host"].(string) + peerEpAddress, peerEpPort, err := net.SplitHostPort(peerEndpoint) + if err != nil { + return err + } + peerPublicKey, _ := peer["public_key"].(string) + peerPort, _ := strconv.Atoi(peerEpPort) + + peers := []map[string]interface{}{ + { + "address": peerEpAddress, + "port": peerPort, + "public_key": peerPublicKey, + "allowed_ips": []string{"0.0.0.0/0", "::/0"}, + "reserved": reserved, + }, + } + + warpData := map[string]interface{}{ + "access_token": token, + "device_id": deviceId, + "license_key": license, + } + + ep.Ext, err = json.MarshalIndent(warpData, "", " ") + if err != nil { + return err + } + + var epOptions map[string]interface{} + err = json.Unmarshal(ep.Options, &epOptions) + if err != nil { + return err + } + epOptions["private_key"] = privateKey.String() + epOptions["address"] = []string{fmt.Sprintf("%s/32", v4), fmt.Sprintf("%s/128", v6)} + epOptions["listen_port"] = 0 + epOptions["peers"] = peers + + ep.Options, err = json.MarshalIndent(epOptions, "", " ") + return err +} + +func (s *WarpService) getReserved(clientID string) []int { + var reserved []int + decoded, err := base64.StdEncoding.DecodeString(clientID) + if err != nil { + return nil + } + + hexString := "" + for _, char := range decoded { + hex := fmt.Sprintf("%02x", char) + hexString += hex + } + + for i := 0; i < len(hexString); i += 2 { + hexByte := hexString[i : i+2] + decValue, err := strconv.ParseInt(hexByte, 16, 32) + if err != nil { + return nil + } + reserved = append(reserved, int(decValue)) + } + + return reserved +} + +func (s *WarpService) SetWarpLicense(old_license string, ep *model.Endpoint) error { + var warpData map[string]string + err := json.Unmarshal(ep.Ext, &warpData) + if err != nil { + return err + } + + if warpData["license_key"] == old_license { + return nil + } + + url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s/account", warpData["device_id"]) + data := fmt.Sprintf(`{"license": "%s"}`, warpData["license_key"]) + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer([]byte(data))) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+warpData["access_token"]) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + buffer := bytes.NewBuffer(make([]byte, 8192)) + buffer.Reset() + _, err = buffer.ReadFrom(resp.Body) + if err != nil { + return err + } + var response map[string]interface{} + err = json.Unmarshal(buffer.Bytes(), &response) + if err != nil { + return err + } + + if success, ok := response["success"].(bool); ok && success == false { + errorArr, _ := response["errors"].([]interface{}) + errorObj := errorArr[0].(map[string]interface{}) + return common.NewError(errorObj["code"], errorObj["message"]) + } + + return nil +} diff --git a/sub/clashService.go b/sub/clashService.go new file mode 100644 index 0000000..f403117 --- /dev/null +++ b/sub/clashService.go @@ -0,0 +1,393 @@ +package sub + +import ( + "strings" + + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/service" + "github.com/alireza0/s-ui/util" + + "gopkg.in/yaml.v3" +) + +type ClashService struct { + service.SettingService + JsonService + LinkService +} + +const basicClashConfig = `mixed-port: 7890 +allow-lan: false +mode: rule +log-level: info +external-controller: 127.0.0.1:9090 +tun: + enable: true + stack: system + auto-route: true + auto-detect-interface: true + dns-hijack: + - any:53 +dns: + enable: true + ipv6: false + enhanced-mode: fake-ip + fake-ip-range: 198.18.0.1/16 + default-nameserver: + - 8.8.8.8 + - 1.1.1.1 + nameserver: + - https://doh.pub/dns-query + - https://1.0.0.1/dns-query + fallback: + - tcp://9.9.9.9:53 + fake-ip-filter: + - "*.lan" + - localhost + - "*.local" +rules: + - GEOIP,Private,DIRECT + - MATCH,Proxy +` + +const ProxyGroups = `- name: Proxy + type: select + proxies: [] +- name: Auto + type: url-test + proxies: [] + url: http://www.gstatic.com/generate_204 + interval: 300 + tolerance: 50 +` + +func (s *ClashService) GetClash(subId string) (*string, []string, error) { + + client, inDatas, err := s.getData(subId) + if err != nil { + return nil, nil, err + } + + outbounds, outTags, err := s.getOutbounds(client.Config, inDatas) + if err != nil { + return nil, nil, err + } + + links := s.LinkService.GetLinks(&client.Links, "external", "") + tagNumEnable := 0 + if len(links) > 1 { + tagNumEnable = 1 + } + for index, link := range links { + json, tag, err := util.GetOutbound(link, (index+1)*tagNumEnable) + if err == nil && len(tag) > 0 { + *outbounds = append(*outbounds, *json) + *outTags = append(*outTags, tag) + } + } + + basicConfig, err := s.getClashConfig() + if err != nil || len(basicConfig) == 0 { + basicConfig = basicClashConfig + } + + resultStr, err := s.ConvertToClashMeta(outbounds, basicConfig) + if err != nil { + return nil, nil, err + } + + updateInterval, _ := s.SettingService.GetSubUpdates() + headers := util.GetHeaders(client, updateInterval) + + return &resultStr, headers, nil +} + +func (s *ClashService) getClashConfig() (string, error) { + subClashExt, err := s.SettingService.GetSubClashExt() + if err != nil { + return "", err + } + + return subClashExt, nil +} + +func (s *ClashService) ConvertToClashMeta(outbounds *[]map[string]interface{}, basicConfig string) (string, error) { + var proxies []interface{} + proxyTags := make([]string, 0) + for _, obMap := range *outbounds { + + t, _ := obMap["type"].(string) + if t == "selector" || t == "urltest" || t == "direct" { + continue + } + + proxy := make(map[string]interface{}) + proxy["name"] = obMap["tag"] + proxy["type"] = t + + server, _ := obMap["server"].(string) + if len(server) > 0 && strings.Contains(server, ":") && !strings.Contains(server, ".") && !(strings.HasPrefix(server, "[") && strings.HasSuffix(server, "]")) { + server = "'[" + server + "]'" + } + proxy["server"] = server + + proxy["port"] = obMap["server_port"] + + switch t { + case "vmess", "vless", "tuic": + proxy["uuid"] = obMap["uuid"] + if t == "vmess" { + if alterId, ok := obMap["alter_id"].(float64); ok { + proxy["alterId"] = int(alterId) + } else { + proxy["alterId"] = 0 + } + proxy["cipher"] = "auto" + } + if t == "vless" { + if flow, ok := obMap["flow"].(string); ok { + proxy["flow"] = flow + } + } + if t == "tuic" { + proxy["password"] = obMap["password"] + if congestion_control, ok := obMap["congestion_control"].(string); ok { + proxy["congestion-controller"] = congestion_control + } + } + case "trojan": + proxy["password"] = obMap["password"] + case "socks", "http": + if t == "socks" { + proxy["type"] = "socks5" + } + proxy["username"] = obMap["username"] + proxy["password"] = obMap["password"] + case "hysteria", "hysteria2": + if _, ok := obMap["up_mbps"].(float64); ok { + proxy["up"] = obMap["up_mbps"] + } + if _, ok := obMap["down_mbps"].(float64); ok { + proxy["down"] = obMap["down_mbps"] + } + if t == "hysteria" { + proxy["auth-str"] = obMap["auth_str"] + if obfs, ok := obMap["obfs"].(string); ok { + proxy["obfs"] = obfs + } + } else { + proxy["password"] = obMap["password"] + if obfs, ok := obMap["obfs"].(map[string]interface{}); ok { + proxy["obfs"] = obfs["type"] + proxy["obfs-password"] = obfs["password"] + } + } + + if portLists, ok := obMap["server_ports"].([]interface{}); ok { + var ports []string + for _, portList := range portLists { + portRange, _ := portList.(string) + ports = append(ports, strings.ReplaceAll(portRange, ":", "-")) + } + proxy["ports"] = strings.Join(ports, ",") + } + case "anytls": + proxy["password"] = obMap["password"] + if tls, ok := obMap["tls"].(map[string]interface{}); ok { + proxy["sni"] = tls["server_name"] + proxy["skip-cert-verify"] = tls["insecure"] + } + case "shadowsocks": + proxy["type"] = "ss" + proxy["cipher"] = obMap["method"] + proxy["password"] = obMap["password"] + if network, ok := obMap["network"].(string); ok && network != "tcp" { + proxy["udp"] = true + } + if uot, ok := obMap["udp_over_tcp"].(bool); ok && uot { + proxy["udp-over-tcp"] = true + } + default: + continue + } + + // TLS params + tls, isTls := obMap["tls"].(map[string]interface{}) + if isTls { + tlsEnabled, ok := tls["enabled"].(bool) + if ok && !tlsEnabled { + isTls = false + } + } + if isTls { + proxy["tls"] = tls["enabled"] + + // ALPN if exists + if alpn, ok := tls["alpn"].([]interface{}); ok { + proxy["alpn"] = alpn + } + + // Add reality if exists + if reality, ok := tls["reality"].(map[string]interface{}); ok && reality["enabled"].(bool) { + reality_opts := make(map[string]interface{}) + if pbk, ok := reality["public_key"].(string); ok { + reality_opts["public-key"] = pbk + } + if sid, ok := reality["short_id"].(string); ok { + reality_opts["short-id"] = sid + } + proxy["reality-opts"] = reality_opts + } + if utls, ok := tls["utls"].(map[string]interface{}); ok { + if enabled, ok := utls["enabled"].(bool); ok && enabled { + if fp, ok := utls["fingerprint"].(string); ok { + proxy["client-fingerprint"] = fp + } + } + } + if sni, ok := tls["server_name"].(string); ok { + if t == "vless" || t == "vmess" { + proxy["servername"] = sni + } else { + proxy["sni"] = sni + } + } + if insecure, ok := tls["insecure"].(bool); ok && insecure { + proxy["skip-cert-verify"] = insecure + } + // ech outbounds + if ech, ok := tls["ech"].(map[string]interface{}); ok && ech["enabled"].(bool) { + ech_config, _ := ech["config"].([]interface{}) + ech_string := "" + for i := 1; i < len(ech_config)-1; i++ { + ech_string += ech_config[i].(string) + } + proxy["ech-opts"] = map[string]interface{}{ + "enable": true, + "config": ech_string, + } + } + } + + // Transport if exist + if transport, ok := obMap["transport"].(map[string]interface{}); ok { + tt, _ := transport["type"].(string) + switch tt { + case "http": + httpOpts := make(map[string]interface{}) + if path, ok := transport["path"].([]interface{}); ok { + httpOpts["path"] = path[0] + } else if path, ok := transport["path"].(string); ok { + httpOpts["path"] = path + } + if host, ok := transport["host"].([]interface{}); ok { + httpOpts["host"] = host[0] + } + if isTls { + proxy["network"] = "h2" + proxy["h2-opts"] = httpOpts + } else { + proxy["network"] = "http" + proxy["http-opts"] = map[string]interface{}{"path": []interface{}{httpOpts["path"]}, "host": httpOpts["host"]} + } + case "ws", "httpupgrade": + proxy["network"] = "ws" + wsOpts := make(map[string]interface{}) + if path, ok := transport["path"].(string); ok { + wsOpts["path"] = path + } + if headers, ok := transport["headers"].([]interface{}); ok { + wsOpts["headers"] = headers + } + if ed, ok := transport["early_data_header_name"].(string); ok { + wsOpts["early-data-header-name"] = ed + } + if tt == "httpupgrade" { + wsOpts["v2ray-http-upgrade"] = true + } + proxy["ws-opts"] = wsOpts + case "grpc": + proxy["network"] = "grpc" + grpcOpts := make(map[string]interface{}) + if service_name, ok := transport["service_name"].(string); ok { + grpcOpts["grpc-service-name"] = service_name + } + proxy["grpc-opts"] = grpcOpts + } + } + + // Multiplex + if mux, ok := obMap["multiplex"].(map[string]interface{}); ok { + if enabled, ok := mux["enabled"].(bool); ok && enabled { + smux := make(map[string]interface{}) + smux["enabled"] = true + if protocol, ok := mux["protocol"].(string); ok { + smux["protocol"] = protocol + } + if _, ok := mux["max_connections"].(float64); ok { + smux["max-connections"] = mux["max_connections"] + } + if _, ok := mux["min_streams"].(float64); ok { + smux["min-streams"] = mux["min_streams"] + } + if _, ok := mux["max_streams"].(float64); ok { + smux["max-streams"] = mux["max_streams"] + } + if _, ok := mux["padding"].(bool); ok { + smux["padding"] = mux["padding"] + } + if brutal, ok := mux["brutal"].(map[string]interface{}); ok { + if enabled, ok := brutal["enabled"].(bool); ok && enabled { + brutalOpts := make(map[string]interface{}) + brutalOpts["enabled"] = true + if _, ok := brutal["up_mbps"].(float64); ok { + brutalOpts["up"] = brutal["up_mbps"] + } + if _, ok := brutal["down_mbps"].(float64); ok { + brutalOpts["down"] = brutal["down_mbps"] + } + smux["brutal-opts"] = brutalOpts + } + } + proxy["smux"] = smux + } + } + + proxies = append(proxies, proxy) + proxyTags = append(proxyTags, obMap["tag"].(string)) + } + + var proxyGroups []map[string]interface{} + err := yaml.Unmarshal([]byte(ProxyGroups), &proxyGroups) + if err != nil { + logger.Error(err.Error()) + } + + proxyGroups[1]["proxies"] = proxyTags + proxyGroups[0]["proxies"] = append([]string{proxyGroups[1]["name"].(string)}, proxyTags...) + + // Merge proxies and proxy groups if exist + var output map[string]interface{} + err = yaml.Unmarshal([]byte(basicConfig), &output) + if err != nil { + logger.Error(err.Error()) + } + + if p, ok := output["proxies"].([]interface{}); ok { + output["proxies"] = append(p, proxies...) + } else { + output["proxies"] = proxies + } + + if pg, ok := output["proxy-groups"].([]interface{}); ok { + output["proxy-groups"] = append(pg, proxyGroups[0], proxyGroups[1]) + } else { + output["proxy-groups"] = proxyGroups + } + + result, err := yaml.Marshal(output) + if err != nil { + return "", err + } + return string(result), nil +} diff --git a/sub/jsonService.go b/sub/jsonService.go new file mode 100644 index 0000000..67939b0 --- /dev/null +++ b/sub/jsonService.go @@ -0,0 +1,326 @@ +package sub + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/service" + "github.com/alireza0/s-ui/util" +) + +const defaultJson = ` +{ + "inbounds": [ + { + "type": "tun", + "address": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ], + "mtu": 9000, + "auto_route": true, + "strict_route": false, + "endpoint_independent_nat": false, + "stack": "system", + "platform": { + "http_proxy": { + "enabled": true, + "server": "127.0.0.1", + "server_port": 2080 + } + } + }, + { + "type": "mixed", + "listen": "127.0.0.1", + "listen_port": 2080, + "users": [] + } + ] +} +` + +type JsonService struct { + service.SettingService + LinkService +} + +func (j *JsonService) GetJson(subId string, format string) (*string, []string, error) { + var jsonConfig map[string]interface{} + + client, inDatas, err := j.getData(subId) + if err != nil { + return nil, nil, err + } + + outbounds, outTags, err := j.getOutbounds(client.Config, inDatas) + if err != nil { + return nil, nil, err + } + + links := j.LinkService.GetLinks(&client.Links, "external", "") + tagNumEnable := 0 + if len(links) > 1 { + tagNumEnable = 1 + } + for index, link := range links { + json, tag, err := util.GetOutbound(link, (index+1)*tagNumEnable) + if err == nil && len(tag) > 0 { + *outbounds = append(*outbounds, *json) + *outTags = append(*outTags, tag) + } + } + + j.addDefaultOutbounds(outbounds, outTags) + + err = json.Unmarshal([]byte(defaultJson), &jsonConfig) + if err != nil { + return nil, nil, err + } + + jsonConfig["outbounds"] = outbounds + + // Add other objects from settings + j.addOthers(&jsonConfig) + + result, _ := json.MarshalIndent(jsonConfig, "", " ") + resultStr := string(result) + + updateInterval, _ := j.SettingService.GetSubUpdates() + headers := util.GetHeaders(client, updateInterval) + + return &resultStr, headers, nil +} + +func (j *JsonService) getData(subId string) (*model.Client, []*model.Inbound, error) { + db := database.GetDB() + client := &model.Client{} + err := db.Model(model.Client{}).Where("enable = true and name = ?", subId).First(client).Error + if err != nil { + return nil, nil, err + } + var clientInbounds []uint + err = json.Unmarshal(client.Inbounds, &clientInbounds) + if err != nil { + return nil, nil, err + } + var inbounds []*model.Inbound + err = db.Model(model.Inbound{}).Preload("Tls").Where("id in ?", clientInbounds).Find(&inbounds).Error + if err != nil { + return nil, nil, err + } + return client, inbounds, nil +} + +func (j *JsonService) getOutbounds(clientConfig json.RawMessage, inbounds []*model.Inbound) (*[]map[string]interface{}, *[]string, error) { + var outbounds []map[string]interface{} + var configs map[string]interface{} + var outTags []string + + err := json.Unmarshal(clientConfig, &configs) + if err != nil { + return nil, nil, err + } + for _, inData := range inbounds { + if len(inData.OutJson) < 5 { + continue + } + var outbound map[string]interface{} + err = json.Unmarshal(inData.OutJson, &outbound) + if err != nil { + return nil, nil, err + } + protocol, _ := outbound["type"].(string) + + // Shadowsocks + if protocol == "shadowsocks" { + var userPass []string + var inbOptions map[string]interface{} + err = json.Unmarshal(inData.Options, &inbOptions) + if err != nil { + return nil, nil, err + } + method, _ := inbOptions["method"].(string) + if strings.HasPrefix(method, "2022") { + inbPass, _ := inbOptions["password"].(string) + userPass = append(userPass, inbPass) + } + var pass string + if method == "2022-blake3-aes-128-gcm" { + pass, _ = configs["shadowsocks16"].(map[string]interface{})["password"].(string) + } else { + pass, _ = configs["shadowsocks"].(map[string]interface{})["password"].(string) + } + userPass = append(userPass, pass) + outbound["password"] = strings.Join(userPass, ":") + } else { // Other protocols + config, _ := configs[protocol].(map[string]interface{}) + for key, value := range config { + if key == "name" || key == "alterId" || (key == "flow" && inData.TlsId == 0) { + continue + } + outbound[key] = value + } + } + + var addrs []map[string]interface{} + err = json.Unmarshal(inData.Addrs, &addrs) + if err != nil { + return nil, nil, err + } + tag, _ := outbound["tag"].(string) + if len(addrs) == 0 { + // For mixed protocol, use separated socks and http + if protocol == "mixed" { + outbound["tag"] = tag + j.pushMixed(&outbounds, &outTags, outbound) + } else { + outTags = append(outTags, tag) + outbounds = append(outbounds, outbound) + } + } else { + for index, addr := range addrs { + // Copy original config + newOut := make(map[string]interface{}, len(outbound)) + for key, value := range outbound { + newOut[key] = value + } + // Change and push copied config + newOut["server"], _ = addr["server"].(string) + port, _ := addr["server_port"].(float64) + newOut["server_port"] = int(port) + + // Override TLS + if addrTls, ok := addr["tls"].(map[string]interface{}); ok { + outTls, _ := newOut["tls"].(map[string]interface{}) + if outTls == nil { + outTls = make(map[string]interface{}) + } + for key, value := range addrTls { + outTls[key] = value + } + newOut["tls"] = outTls + } + + remark, _ := addr["remark"].(string) + newTag := fmt.Sprintf("%d.%s%s", index+1, tag, remark) + newOut["tag"] = newTag + // For mixed protocol, use separated socks and http + if protocol == "mixed" { + j.pushMixed(&outbounds, &outTags, newOut) + } else { + outTags = append(outTags, newTag) + outbounds = append(outbounds, newOut) + } + } + } + } + return &outbounds, &outTags, nil +} + +func (j *JsonService) addDefaultOutbounds(outbounds *[]map[string]interface{}, outTags *[]string) { + outbound := []map[string]interface{}{ + { + "outbounds": append([]string{"auto", "direct"}, *outTags...), + "tag": "proxy", + "type": "selector", + }, + { + "tag": "auto", + "type": "urltest", + "outbounds": outTags, + "url": "http://www.gstatic.com/generate_204", + "interval": "10m", + "tolerance": 50, + }, + { + "type": "direct", + "tag": "direct", + }, + } + *outbounds = append(outbound, *outbounds...) +} + +func (j *JsonService) addOthers(jsonConfig *map[string]interface{}) error { + rules_start := []interface{}{ + map[string]interface{}{ + "action": "sniff", + }, + map[string]interface{}{ + "clash_mode": "Direct", + "action": "route", + "outbound": "direct", + }, + } + rules_end := []interface{}{ + map[string]interface{}{ + "clash_mode": "Global", + "action": "route", + "outbound": "proxy", + }, + } + route := map[string]interface{}{ + "auto_detect_interface": true, + "final": "proxy", + "rules": rules_start, + } + + othersStr, err := j.SettingService.GetSubJsonExt() + if err != nil { + return err + } + if len(othersStr) == 0 { + (*jsonConfig)["route"] = route + return nil + } + var othersJson map[string]interface{} + err = json.Unmarshal([]byte(othersStr), &othersJson) + if err != nil { + return err + } + if _, ok := othersJson["log"]; ok { + (*jsonConfig)["log"] = othersJson["log"] + } + if _, ok := othersJson["dns"]; ok { + (*jsonConfig)["dns"] = othersJson["dns"] + } + if _, ok := othersJson["inbounds"]; ok { + (*jsonConfig)["inbounds"] = othersJson["inbounds"] + } + if _, ok := othersJson["experimental"]; ok { + (*jsonConfig)["experimental"] = othersJson["experimental"] + } + if _, ok := othersJson["rule_set"]; ok { + route["rule_set"] = othersJson["rule_set"] + } + if settingRules, ok := othersJson["rules"].([]interface{}); ok { + rules := append(rules_start, settingRules...) + route["rules"] = append(rules, rules_end...) + } + if defaultDomainResolver, ok := othersJson["default_domain_resolver"].(string); ok { + route["default_domain_resolver"] = defaultDomainResolver + } + (*jsonConfig)["route"] = route + + return nil +} + +func (j *JsonService) pushMixed(outbounds *[]map[string]interface{}, outTags *[]string, out map[string]interface{}) { + socksOut := make(map[string]interface{}, 1) + httpOut := make(map[string]interface{}, 1) + for key, value := range out { + socksOut[key] = value + httpOut[key] = value + } + socksTag := fmt.Sprintf("%s-socks", out["tag"]) + httpTag := fmt.Sprintf("%s-http", out["tag"]) + socksOut["type"] = "socks" + httpOut["type"] = "http" + socksOut["tag"] = socksTag + httpOut["tag"] = httpTag + *outbounds = append(*outbounds, socksOut, httpOut) + *outTags = append(*outTags, socksTag, httpTag) +} diff --git a/sub/linkService.go b/sub/linkService.go new file mode 100644 index 0000000..90b7ebc --- /dev/null +++ b/sub/linkService.go @@ -0,0 +1,74 @@ +package sub + +import ( + "encoding/json" + "strings" + + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util" +) + +type Link struct { + Type string `json:"type"` + Remark string `json:"remark"` + Uri string `json:"uri"` +} + +type LinkService struct { +} + +func (s *LinkService) GetLinks(linkJson *json.RawMessage, types string, clientInfo string) []string { + links := []Link{} + var result []string + err := json.Unmarshal(*linkJson, &links) + if err != nil { + return nil + } + for _, link := range links { + switch link.Type { + case "external": + result = append(result, link.Uri) + case "sub": + subLinks := util.GetExternalLink(link.Uri) + result = append(result, strings.Split(subLinks, "\n")...) + case "local": + if types == "all" { + result = append(result, s.addClientInfo(link.Uri, clientInfo)) + } + } + } + return result +} + +func (s *LinkService) addClientInfo(uri string, clientInfo string) string { + if len(clientInfo) == 0 { + return uri + } + protocol := strings.Split(uri, "://") + if len(protocol) < 2 { + return uri + } + switch protocol[0] { + case "vmess": + var vmessJson map[string]interface{} + config, err := util.B64StrToByte(protocol[1]) + if err != nil { + logger.Warning("sub: Error decoding vmess content:", err) + return uri + } + err = json.Unmarshal(config, &vmessJson) + if err != nil { + logger.Warning("sub: Error decoding vmess content:", err) + return uri + } + vmessJson["ps"] = vmessJson["ps"].(string) + clientInfo + result, err := json.MarshalIndent(vmessJson, "", " ") + if err != nil { + logger.Warning("sub: Error decoding vmess + clientInfo content:", err) + return uri + } + return "vmess://" + util.ByteToB64Str(result) + default: + return uri + clientInfo + } +} diff --git a/sub/sub.go b/sub/sub.go new file mode 100644 index 0000000..ac7a7a6 --- /dev/null +++ b/sub/sub.go @@ -0,0 +1,162 @@ +package sub + +import ( + "context" + "crypto/tls" + "io" + "net" + "net/http" + "strconv" + "time" + + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/middleware" + "github.com/alireza0/s-ui/network" + "github.com/alireza0/s-ui/service" + + "github.com/gin-gonic/gin" +) + +type Server struct { + httpServer *http.Server + listener net.Listener + ctx context.Context + cancel context.CancelFunc + + service.SettingService +} + +func NewServer() *Server { + ctx, cancel := context.WithCancel(context.Background()) + return &Server{ + ctx: ctx, + cancel: cancel, + } +} + +func (s *Server) initRouter() (*gin.Engine, error) { + if config.IsDebug() { + gin.SetMode(gin.DebugMode) + } else { + gin.DefaultWriter = io.Discard + gin.DefaultErrorWriter = io.Discard + gin.SetMode(gin.ReleaseMode) + } + + engine := gin.Default() + + subPath, err := s.SettingService.GetSubPath() + if err != nil { + return nil, err + } + + subDomain, err := s.SettingService.GetSubDomain() + if err != nil { + return nil, err + } + + if subDomain != "" { + engine.Use(middleware.DomainValidator(subDomain)) + } + + g := engine.Group(subPath) + NewSubHandler(g) + + return engine, nil +} + +func (s *Server) Start() (err error) { + //This is an anonymous function, no function name + defer func() { + if err != nil { + s.Stop() + } + }() + + engine, err := s.initRouter() + if err != nil { + return err + } + + certFile, err := s.SettingService.GetSubCertFile() + if err != nil { + return err + } + keyFile, err := s.SettingService.GetSubKeyFile() + if err != nil { + return err + } + listen, err := s.SettingService.GetSubListen() + if err != nil { + return err + } + port, err := s.SettingService.GetSubPort() + if err != nil { + return err + } + + listenAddr := net.JoinHostPort(listen, strconv.Itoa(port)) + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + return err + } + + if certFile != "" || keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + listener.Close() + return err + } + c := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + listener = network.NewAutoHttpsListener(listener) + listener = tls.NewListener(listener, c) + } + + if certFile != "" || keyFile != "" { + logger.Info("Sub server run https on", listener.Addr()) + } else { + logger.Info("Sub server run http on", listener.Addr()) + } + s.listener = listener + + s.httpServer = &http.Server{ + Handler: engine, + } + + go func() { + s.httpServer.Serve(listener) + }() + + return nil +} + +func (s *Server) Stop() error { + var err error + if s.httpServer != nil { + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 30*time.Second) + err = s.httpServer.Shutdown(shutdownCtx) + cancelShutdown() + if err != nil { + s.cancel() + if s.listener != nil { + _ = s.listener.Close() + } + return err + } + } else if s.listener != nil { + err = s.listener.Close() + if err != nil { + s.cancel() + return err + } + } + s.cancel() + return nil +} + +func (s *Server) GetCtx() context.Context { + return s.ctx +} diff --git a/sub/subHandler.go b/sub/subHandler.go new file mode 100644 index 0000000..1a8cdc2 --- /dev/null +++ b/sub/subHandler.go @@ -0,0 +1,78 @@ +package sub + +import ( + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/service" + + "github.com/gin-gonic/gin" +) + +type SubHandler struct { + service.SettingService + SubService + JsonService + ClashService +} + +func NewSubHandler(g *gin.RouterGroup) { + a := &SubHandler{} + a.initRouter(g) +} + +func (s *SubHandler) initRouter(g *gin.RouterGroup) { + g.GET("/:subid", s.subs) + g.HEAD("/:subid", s.subHeaders) +} + +func (s *SubHandler) subs(c *gin.Context) { + var headers []string + var result *string + var err error + subId := c.Param("subid") + format, isFormat := c.GetQuery("format") + if isFormat { + switch format { + case "json": + result, headers, err = s.JsonService.GetJson(subId, format) + case "clash": + result, headers, err = s.ClashService.GetClash(subId) + } + if err != nil || result == nil { + logger.Error(err) + c.String(400, "Error!") + return + } + } else { + result, headers, err = s.SubService.GetSubs(subId) + if err != nil || result == nil { + logger.Error(err) + c.String(400, "Error!") + return + } + } + + s.addHeaders(c, headers) + + c.String(200, *result) +} + +func (s *SubHandler) subHeaders(c *gin.Context) { + subId := c.Param("subid") + client, err := s.SubService.getClientBySubId(subId) + if err != nil { + logger.Error(err) + c.String(400, "Error!") + return + } + + headers := s.SubService.getClientHeaders(client) + s.addHeaders(c, headers) + + c.Status(200) +} + +func (s *SubHandler) addHeaders(c *gin.Context, headers []string) { + c.Writer.Header().Set("Subscription-Userinfo", headers[0]) + c.Writer.Header().Set("Profile-Update-Interval", headers[1]) + c.Writer.Header().Set("Profile-Title", headers[2]) +} diff --git a/sub/subService.go b/sub/subService.go new file mode 100644 index 0000000..a08b6f4 --- /dev/null +++ b/sub/subService.go @@ -0,0 +1,93 @@ +package sub + +import ( + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/service" + "github.com/alireza0/s-ui/util" +) + +type SubService struct { + service.SettingService + LinkService +} + +func (s *SubService) GetSubs(subId string) (*string, []string, error) { + var err error + + client, err := s.getClientBySubId(subId) + if err != nil { + return nil, nil, err + } + + clientInfo := "" + subShowInfo, _ := s.SettingService.GetSubShowInfo() + if subShowInfo { + clientInfo = s.getClientInfo(client) + } + + linksArray := s.LinkService.GetLinks(&client.Links, "all", clientInfo) + result := strings.Join(linksArray, "\n") + + headers := s.getClientHeaders(client) + + subEncode, _ := s.SettingService.GetSubEncode() + if subEncode { + result = base64.StdEncoding.EncodeToString([]byte(result)) + } + + return &result, headers, nil +} + +func (j *SubService) getClientBySubId(subId string) (*model.Client, error) { + db := database.GetDB() + client := &model.Client{} + err := db.Model(model.Client{}).Where("enable = true and name = ?", subId).First(client).Error + if err != nil { + return nil, err + } + return client, nil +} + +func (s *SubService) getClientHeaders(client *model.Client) []string { + updateInterval, _ := s.SettingService.GetSubUpdates() + return util.GetHeaders(client, updateInterval) +} + +func (s *SubService) getClientInfo(c *model.Client) string { + now := time.Now().Unix() + + var result []string + if vol := c.Volume - (c.Up + c.Down); vol > 0 { + result = append(result, fmt.Sprintf("%s%s", s.formatTraffic(vol), "📊")) + } + if c.Expiry > 0 { + result = append(result, fmt.Sprintf("%d%s⏳", (c.Expiry-now)/86400, "Days")) + } + if len(result) > 0 { + return " " + strings.Join(result, " ") + } else { + return " ♾" + } +} + +func (s *SubService) formatTraffic(trafficBytes int64) string { + if trafficBytes < 1024 { + return fmt.Sprintf("%.2fB", float64(trafficBytes)/float64(1)) + } else if trafficBytes < (1024 * 1024) { + return fmt.Sprintf("%.2fKB", float64(trafficBytes)/float64(1024)) + } else if trafficBytes < (1024 * 1024 * 1024) { + return fmt.Sprintf("%.2fMB", float64(trafficBytes)/float64(1024*1024)) + } else if trafficBytes < (1024 * 1024 * 1024 * 1024) { + return fmt.Sprintf("%.2fGB", float64(trafficBytes)/float64(1024*1024*1024)) + } else if trafficBytes < (1024 * 1024 * 1024 * 1024 * 1024) { + return fmt.Sprintf("%.2fTB", float64(trafficBytes)/float64(1024*1024*1024*1024)) + } else { + return fmt.Sprintf("%.2fEB", float64(trafficBytes)/float64(1024*1024*1024*1024*1024)) + } +} diff --git a/util/base64.go b/util/base64.go new file mode 100644 index 0000000..c2327b0 --- /dev/null +++ b/util/base64.go @@ -0,0 +1,20 @@ +package util + +import "encoding/base64" + +// Function to return decoded bytes if a string is Base64 encoded +func StrOrBase64Encoded(str string) string { + decoded, err := base64.StdEncoding.DecodeString(str) + if err == nil { + return string(decoded) + } + return str +} + +func B64StrToByte(str string) ([]byte, error) { + return base64.StdEncoding.DecodeString(str) +} + +func ByteToB64Str(b []byte) string { + return base64.StdEncoding.EncodeToString(b) +} diff --git a/util/common/array.go b/util/common/array.go new file mode 100644 index 0000000..46a1716 --- /dev/null +++ b/util/common/array.go @@ -0,0 +1,45 @@ +package common + +// UnionUintArray returns a new unique slice that contains all elements from both input slices +func UnionUintArray(a []uint, b []uint) []uint { + m := make(map[uint]bool) + for _, v := range a { + m[v] = true + } + for _, v := range b { + m[v] = true + } + var res []uint + for k := range m { + res = append(res, k) + } + return res +} + +// Find different elements in two slices +// Returns elements in 'a' that are not in 'b' and elements in 'b' that are not in 'a' +func DiffUintArray(a []uint, b []uint) []uint { + different := []uint{} + set := make(map[uint]bool) + + for _, item := range a { + set[item] = true + } + for _, item := range b { + if !set[item] { + different = append(different, item) + } + } + + set = make(map[uint]bool) + for _, item := range b { + set[item] = true + } + for _, item := range a { + if !set[item] { + different = append(different, item) + } + } + + return different +} diff --git a/util/common/err.go b/util/common/err.go new file mode 100644 index 0000000..0dc3514 --- /dev/null +++ b/util/common/err.go @@ -0,0 +1,28 @@ +package common + +import ( + "errors" + "fmt" + + "github.com/alireza0/s-ui/logger" +) + +func NewErrorf(format string, a ...interface{}) error { + msg := fmt.Sprintf(format, a...) + return errors.New(msg) +} + +func NewError(a ...interface{}) error { + msg := fmt.Sprintln(a...) + return errors.New(msg) +} + +func Recover(msg string) interface{} { + panicErr := recover() + if panicErr != nil { + if msg != "" { + logger.Error(msg, "panic:", panicErr) + } + } + return panicErr +} diff --git a/util/common/random.go b/util/common/random.go new file mode 100644 index 0000000..e4940ec --- /dev/null +++ b/util/common/random.go @@ -0,0 +1,55 @@ +package common + +import ( + crand "crypto/rand" + "math/big" + mrand "math/rand" + "sync" + "time" +) + +var ( + allSeq []rune = []rune{ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + } + + fallbackRand = mrand.New(mrand.NewSource(time.Now().UnixNano())) + fallbackMu = sync.Mutex{} +) + +func Random(n int) string { + if n <= 0 || len(allSeq) == 0 { + return "" + } + result := make([]rune, n) + maxBig := big.NewInt(int64(len(allSeq))) + for i := 0; i < n; i++ { + num, err := crand.Int(crand.Reader, maxBig) + if err != nil { + // fallback + fallbackMu.Lock() + result[i] = allSeq[fallbackRand.Intn(len(allSeq))] + fallbackMu.Unlock() + continue + } + result[i] = allSeq[int(num.Int64())] + } + return string(result) +} + +func RandomInt(n int) int { + if n <= 0 { + return 0 + } + max := big.NewInt(int64(n)) + result, err := crand.Int(crand.Reader, max) + if err != nil { + // fallback + fallbackMu.Lock() + defer fallbackMu.Unlock() + return fallbackRand.Intn(n) + } + return int(result.Int64()) +} diff --git a/util/genLink.go b/util/genLink.go new file mode 100644 index 0000000..6f5615d --- /dev/null +++ b/util/genLink.go @@ -0,0 +1,615 @@ +package util + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/util/common" +) + +var InboundTypeWithLink = []string{"socks", "http", "mixed", "shadowsocks", "naive", "hysteria", "hysteria2", "anytls", "tuic", "vless", "trojan", "vmess"} + +type LinkParam struct { + Key string + Value string +} + +func LinkGenerator(clientConfig json.RawMessage, i *model.Inbound, hostname string) []string { + inbound, err := i.MarshalFull() + if err != nil { + return []string{} + } + + var tls map[string]interface{} + if i.TlsId > 0 { + tls = prepareTls(i.Tls) + } + + var userConfig map[string]map[string]interface{} + if err := json.Unmarshal(clientConfig, &userConfig); err != nil { + return []string{} + } + + var Addrs []map[string]interface{} + if err := json.Unmarshal(i.Addrs, &Addrs); err != nil { + return []string{} + } + if len(Addrs) == 0 { + Addrs = append(Addrs, map[string]interface{}{ + "server": hostname, + "server_port": (*inbound)["listen_port"], + "remark": i.Tag, + }) + if i.TlsId > 0 { + Addrs[0]["tls"] = tls + } + } else { + for index, addr := range Addrs { + addrRemark, _ := addr["remark"].(string) + Addrs[index]["remark"] = i.Tag + addrRemark + if i.TlsId > 0 { + newTls := map[string]interface{}{} + for k, v := range tls { + newTls[k] = v + } + + // Override tls + if addrTls, ok := addr["tls"].(map[string]interface{}); ok { + for k, v := range addrTls { + newTls[k] = v + } + } + Addrs[index]["tls"] = newTls + } + } + } + + switch i.Type { + case "socks": + return socksLink(userConfig["socks"], Addrs) + case "http": + return httpLink(userConfig["http"], Addrs) + case "mixed": + return append( + socksLink(userConfig["socks"], Addrs), + httpLink(userConfig["http"], Addrs)..., + ) + case "shadowsocks": + return shadowsocksLink(userConfig, *inbound, Addrs) + case "naive": + return naiveLink(userConfig["naive"], *inbound, Addrs) + case "hysteria": + return hysteriaLink(userConfig["hysteria"], *inbound, Addrs) + case "hysteria2": + return hysteria2Link(userConfig["hysteria2"], *inbound, Addrs) + case "tuic": + return tuicLink(userConfig["tuic"], *inbound, Addrs) + case "vless": + return vlessLink(userConfig["vless"], *inbound, Addrs) + case "anytls": + return anytlsLink(userConfig["anytls"], Addrs) + case "trojan": + return trojanLink(userConfig["trojan"], *inbound, Addrs) + case "vmess": + return vmessLink(userConfig["vmess"], *inbound, Addrs) + } + + return []string{} +} + +func prepareTls(t *model.Tls) map[string]interface{} { + var iTls, oTls map[string]interface{} + if err := json.Unmarshal(t.Client, &oTls); err != nil { + return nil + } + if err := json.Unmarshal(t.Server, &iTls); err != nil { + return nil + } + + for k, v := range iTls { + switch k { + case "enabled", "server_name", "alpn": + oTls[k] = v + case "reality": + reality := v.(map[string]interface{}) + clientReality := oTls["reality"].(map[string]interface{}) + clientReality["enabled"] = reality["enabled"] + if shortIDs, hasSIds := reality["short_id"].([]interface{}); hasSIds && len(shortIDs) > 0 { + clientReality["short_id"] = shortIDs[common.RandomInt(len(shortIDs))] + } + oTls["reality"] = clientReality + } + } + return oTls +} + +func socksLink(userConfig map[string]interface{}, addrs []map[string]interface{}) []string { + var links []string + for _, addr := range addrs { + links = append(links, fmt.Sprintf("socks5://%s:%s@%s:%d", userConfig["username"], userConfig["password"], addr["server"].(string), uint(addr["server_port"].(float64)))) + } + return links +} + +func httpLink(userConfig map[string]interface{}, addrs []map[string]interface{}) []string { + var links []string + protocol := "http" + for _, addr := range addrs { + if addr["tls"] != nil { + protocol = "https" + } + links = append(links, fmt.Sprintf("%s://%s:%s@%s:%d", protocol, userConfig["username"], userConfig["password"], addr["server"].(string), uint(addr["server_port"].(float64)))) + } + return links +} + +func shadowsocksLink( + userConfig map[string]map[string]interface{}, + inbound map[string]interface{}, + addrs []map[string]interface{}) []string { + + var userPass []string + method, _ := inbound["method"].(string) + if strings.HasPrefix(method, "2022") { + inbPass, _ := inbound["password"].(string) + userPass = append(userPass, inbPass) + } + var pass string + if method == "2022-blake3-aes-128-gcm" { + pass, _ = userConfig["shadowsocks16"]["password"].(string) + } else { + pass, _ = userConfig["shadowsocks"]["password"].(string) + } + userPass = append(userPass, pass) + + uriBase := fmt.Sprintf("ss://%s", toBase64([]byte(fmt.Sprintf("%s:%s", method, strings.Join(userPass, ":"))))) + + var links []string + for _, addr := range addrs { + port, _ := addr["server_port"].(float64) + links = append(links, fmt.Sprintf("%s@%s:%.0f#%s", uriBase, addr["server"].(string), port, addr["remark"].(string))) + } + return links +} + +func naiveLink( + userConfig map[string]interface{}, + inbound map[string]interface{}, + addrs []map[string]interface{}) []string { + + password, _ := userConfig["password"].(string) + username, _ := userConfig["username"].(string) + + baseUri := "http2://" + var links []string + + for _, addr := range addrs { + var params []LinkParam + params = append(params, LinkParam{"padding", "1"}) + if tls, ok := addr["tls"].(map[string]interface{}); ok { + if sni, ok := tls["server_name"].(string); ok { + params = append(params, LinkParam{"peer", sni}) + } + if alpn, ok := tls["alpn"].([]interface{}); ok { + alpnList := make([]string, len(alpn)) + for i, v := range alpn { + alpnList[i] = v.(string) + } + params = append(params, LinkParam{"alpn", strings.Join(alpnList, ",")}) + } + if insecure, ok := tls["insecure"].(bool); ok && insecure { + params = append(params, LinkParam{"insecure", "1"}) + } + } + if tfo, ok := inbound["tcp_fast_open"].(bool); ok && tfo { + params = append(params, LinkParam{"tfo", "1"}) + } else { + params = append(params, LinkParam{"tfo", "0"}) + } + + port, _ := addr["server_port"].(float64) + uri := baseUri + toBase64([]byte(fmt.Sprintf("%s:%s@%s:%.0f", username, password, addr["server"].(string), port))) + links = append(links, addParams(uri, params, addr["remark"].(string))) + } + return links +} + +func hysteriaLink( + userConfig map[string]interface{}, + inbound map[string]interface{}, + addrs []map[string]interface{}) []string { + + baseUri := "hysteria://" + var links []string + + for _, addr := range addrs { + var params []LinkParam + if upmbps, ok := inbound["up_mbps"].(float64); ok { + params = append(params, LinkParam{"downmbps", fmt.Sprintf("%.0f", upmbps)}) + } + if downmbps, ok := inbound["down_mbps"].(float64); ok { + params = append(params, LinkParam{"upmbps", fmt.Sprintf("%.0f", downmbps)}) + } + if auth, ok := userConfig["auth_str"].(string); ok { + params = append(params, LinkParam{"auth", auth}) + } + if tls, ok := addr["tls"].(map[string]interface{}); ok { + getTlsParams(¶ms, tls, "insecure") + } + if obfs, ok := inbound["obfs"].(string); ok { + params = append(params, LinkParam{"obfs", obfs}) + } + if tfo, ok := inbound["tcp_fast_open"].(bool); ok && tfo { + params = append(params, LinkParam{"fastopen", "1"}) + } else { + params = append(params, LinkParam{"fastopen", "0"}) + } + var outJson map[string]interface{} + if err := json.Unmarshal(inbound["out_json"].(json.RawMessage), &outJson); err != nil { + return []string{} // Handle error + } + if mport, ok := outJson["server_ports"].([]interface{}); ok { + mportList := make([]string, len(mport)) + for i, v := range mport { + mportList[i] = v.(string) + } + params = append(params, LinkParam{"mport", strings.Join(mportList, ",")}) + } + + port, _ := addr["server_port"].(float64) + uri := fmt.Sprintf("%s%s:%.0f", baseUri, addr["server"].(string), port) + links = append(links, addParams(uri, params, addr["remark"].(string))) + } + + return links +} + +func hysteria2Link( + userConfig map[string]interface{}, + inbound map[string]interface{}, + addrs []map[string]interface{}) []string { + + password, _ := userConfig["password"].(string) + baseUri := fmt.Sprintf("%s%s@", "hysteria2://", password) + var links []string + + for _, addr := range addrs { + var params []LinkParam + if upmbps, ok := inbound["up_mbps"].(float64); ok { + params = append(params, LinkParam{"downmbps", fmt.Sprintf("%.0f", upmbps)}) + } + if downmbps, ok := inbound["down_mbps"].(float64); ok { + params = append(params, LinkParam{"upmbps", fmt.Sprintf("%.0f", downmbps)}) + } + if tls, ok := addr["tls"].(map[string]interface{}); ok { + getTlsParams(¶ms, tls, "insecure") + } + if obfs, ok := inbound["obfs"].(map[string]interface{}); ok { + if obfsType, ok := obfs["type"].(string); ok { + params = append(params, LinkParam{"obfs", obfsType}) + } + if obfsPassword, ok := obfs["password"].(string); ok { + params = append(params, LinkParam{"obfs-password", obfsPassword}) + } + } + if tfo, ok := inbound["tcp_fast_open"].(bool); ok && tfo { + params = append(params, LinkParam{"fastopen", "1"}) + } else { + params = append(params, LinkParam{"fastopen", "0"}) + } + var outJson map[string]interface{} + if err := json.Unmarshal(inbound["out_json"].(json.RawMessage), &outJson); err != nil { + return []string{} // Handle error + } + if mport, ok := outJson["server_ports"].([]interface{}); ok { + mportList := make([]string, len(mport)) + for i, v := range mport { + mportList[i] = v.(string) + } + params = append(params, LinkParam{"mport", strings.Join(mportList, ",")}) + } + + port, _ := addr["server_port"].(float64) + uri := fmt.Sprintf("%s%s:%.0f", baseUri, addr["server"].(string), port) + links = append(links, addParams(uri, params, addr["remark"].(string))) + } + + return links +} + +func anytlsLink( + userConfig map[string]interface{}, + addrs []map[string]interface{}) []string { + + password, _ := userConfig["password"].(string) + baseUri := fmt.Sprintf("%s%s@", "anytls://", password) + var links []string + + for _, addr := range addrs { + var params []LinkParam + if tls, ok := addr["tls"].(map[string]interface{}); ok { + getTlsParams(¶ms, tls, "insecure") + } + + port, _ := addr["server_port"].(float64) + uri := fmt.Sprintf("%s%s:%.0f", baseUri, addr["server"].(string), port) + links = append(links, addParams(uri, params, addr["remark"].(string))) + } + + return links +} + +func tuicLink( + userConfig map[string]interface{}, + inbound map[string]interface{}, + addrs []map[string]interface{}) []string { + + password, _ := userConfig["password"].(string) + uuid, _ := userConfig["uuid"].(string) + baseUri := fmt.Sprintf("%s%s:%s@", "tuic://", uuid, password) + var links []string + + for _, addr := range addrs { + var params []LinkParam + if tls, ok := addr["tls"].(map[string]interface{}); ok { + getTlsParams(¶ms, tls, "insecure") + } + if congestionControl, ok := inbound["congestion_control"].(string); ok { + params = append(params, LinkParam{"congestion_control", congestionControl}) + } + + port, _ := addr["server_port"].(float64) + uri := fmt.Sprintf("%s%s:%.0f", baseUri, addr["server"].(string), port) + links = append(links, addParams(uri, params, addr["remark"].(string))) + } + + return links +} + +func vlessLink( + userConfig map[string]interface{}, + inbound map[string]interface{}, + addrs []map[string]interface{}) []string { + + uuid, _ := userConfig["uuid"].(string) + baseParams := getTransportParams(inbound["transport"]) + var links []string + + for _, addr := range addrs { + params := make([]LinkParam, len(baseParams)) + copy(params, baseParams) + if tls, ok := addr["tls"].(map[string]interface{}); ok && tls["enabled"].(bool) { + getTlsParams(¶ms, tls, "allowInsecure") + if flow, ok := userConfig["flow"].(string); ok { + params = append(params, LinkParam{"flow", flow}) + } + } + port, _ := addr["server_port"].(float64) + uri := fmt.Sprintf("vless://%s@%s:%.0f", uuid, addr["server"].(string), port) + uri = addParams(uri, params, addr["remark"].(string)) + links = append(links, uri) + } + + return links +} + +func trojanLink( + userConfig map[string]interface{}, + inbound map[string]interface{}, + addrs []map[string]interface{}) []string { + password, _ := userConfig["password"].(string) + baseParams := getTransportParams(inbound["transport"]) + var links []string + + for _, addr := range addrs { + params := make([]LinkParam, len(baseParams)) + copy(params, baseParams) + if tls, ok := addr["tls"].(map[string]interface{}); ok && tls["enabled"].(bool) { + getTlsParams(¶ms, tls, "allowInsecure") + } + port, _ := addr["server_port"].(float64) + uri := fmt.Sprintf("trojan://%s@%s:%.0f", password, addr["server"].(string), port) + uri = addParams(uri, params, addr["remark"].(string)) + links = append(links, uri) + } + + return links +} + +func vmessLink( + userConfig map[string]interface{}, + inbound map[string]interface{}, + addrs []map[string]interface{}) []string { + + uuid, _ := userConfig["uuid"].(string) + transportParams := getTransportParams(inbound["transport"]) + var links []string + + baseParams := map[string]interface{}{ + "v": "2", + "id": uuid, + "aid": 0, + } + + var net, typ, host, path string + for _, p := range transportParams { + switch p.Key { + case "type": + net = p.Value + case "host": + host = p.Value + case "path": + path = p.Value + } + } + + if net == "http" || net == "tcp" { + baseParams["net"] = "tcp" + if net == "http" { + typ = "http" + } + } else { + baseParams["net"] = net + } + + for _, addr := range addrs { + obj := make(map[string]interface{}) + for k, v := range baseParams { + obj[k] = v + } + + obj["add"], _ = addr["server"].(string) + port, _ := addr["server_port"].(float64) + obj["port"] = fmt.Sprintf("%.0f", port) + obj["ps"], _ = addr["remark"].(string) + if typ != "" { + obj["type"] = typ + } + if host != "" { + obj["host"] = host + } + if path != "" { + obj["path"] = path + } + populateVmessTlsParams(obj, addr["tls"]) + + jsonStr, _ := json.Marshal(obj) + + uri := fmt.Sprintf("vmess://%s", toBase64(jsonStr)) + links = append(links, uri) + } + return links +} + +func populateVmessTlsParams(obj map[string]interface{}, tlsConfig interface{}) { + if tlsMap, ok := tlsConfig.(map[string]interface{}); ok && tlsMap["enabled"].(bool) { + obj["tls"] = "tls" + var tlsParams []LinkParam + getTlsParams(&tlsParams, tlsMap, "allowInsecure") + for _, p := range tlsParams { + switch p.Key { + case "security": + // ignore, as "tls" is already set + case "allowInsecure": + obj["allowInsecure"] = 1 + case "sni": + obj["sni"] = p.Value + case "fp": + obj["fp"] = p.Value + case "alpn": + obj["alpn"] = p.Value + } + } + } else { + obj["tls"] = "none" + } +} + +func toBase64(d []byte) string { + return base64.StdEncoding.EncodeToString(d) +} + +func addParams(uri string, params []LinkParam, remark string) string { + URL, _ := url.Parse(uri) + var q []string + for _, p := range params { + switch p.Key { + case "mport", "alpn": + q = append(q, fmt.Sprintf("%s=%s", p.Key, p.Value)) + default: + q = append(q, fmt.Sprintf("%s=%s", p.Key, url.QueryEscape(p.Value))) + } + } + URL.RawQuery = strings.Join(q, "&") + URL.Fragment = remark + return URL.String() +} + +func getTransportParams(t interface{}) []LinkParam { + var params []LinkParam + trasport, _ := t.(map[string]interface{}) + var transportType string + if tt, ok := trasport["type"].(string); ok { + transportType = tt + } else { + transportType = "tcp" + } + params = append(params, LinkParam{"type", transportType}) + if transportType == "tcp" { + return params + } + + switch transportType { + case "http": + if host, ok := trasport["host"].([]interface{}); ok { + var hosts []string + for _, v := range host { + hosts = append(hosts, v.(string)) + } + params = append(params, LinkParam{"host", strings.Join(hosts, ",")}) + } + if path, ok := trasport["path"].(string); ok { + params = append(params, LinkParam{"path", path}) + } + case "ws": + if path, ok := trasport["path"].(string); ok { + params = append(params, LinkParam{"path", path}) + } + if headers, ok := trasport["headers"].(map[string]interface{}); ok { + if host, ok := headers["Host"].(string); ok { + params = append(params, LinkParam{"host", host}) + } + } + case "grpc": + if serviceName, ok := trasport["service_name"].(string); ok { + params = append(params, LinkParam{"serviceName", serviceName}) + } + case "httpupgrade": + if host, ok := trasport["host"].(string); ok { + params = append(params, LinkParam{"host", host}) + } + if path, ok := trasport["path"].(string); ok { + params = append(params, LinkParam{"path", path}) + } + } + return params +} + +func getTlsParams(params *[]LinkParam, tls map[string]interface{}, insecureKey string) { + if reality, ok := tls["reality"].(map[string]interface{}); ok && reality["enabled"].(bool) { + *params = append(*params, LinkParam{"security", "reality"}) + if pbk, ok := reality["public_key"].(string); ok { + *params = append(*params, LinkParam{"pbk", pbk}) + } + if sid, ok := reality["short_id"].(string); ok { + *params = append(*params, LinkParam{"sid", sid}) + } + } else { + *params = append(*params, LinkParam{"security", "tls"}) + if insecure, ok := tls["insecure"].(bool); ok && insecure { + *params = append(*params, LinkParam{insecureKey, "1"}) + } + if disableSni, ok := tls["disable_sni"].(bool); ok && disableSni { + *params = append(*params, LinkParam{"disable_sni", "1"}) + } + } + if utls, ok := tls["utls"].(map[string]interface{}); ok { + if fingerprint, ok := utls["fingerprint"].(string); ok { + *params = append(*params, LinkParam{"fp", fingerprint}) + } + } + if sni, ok := tls["server_name"].(string); ok { + *params = append(*params, LinkParam{"sni", sni}) + } + if alpn, ok := tls["alpn"].([]interface{}); ok { + alpnList := make([]string, len(alpn)) + for i, v := range alpn { + alpnList[i] = v.(string) + } + *params = append(*params, LinkParam{"alpn", strings.Join(alpnList, ",")}) + } +} diff --git a/util/linkToJson.go b/util/linkToJson.go new file mode 100644 index 0000000..4c71d3a --- /dev/null +++ b/util/linkToJson.go @@ -0,0 +1,581 @@ +package util + +import ( + "encoding/json" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "github.com/alireza0/s-ui/util/common" +) + +func GetOutbound(uri string, i int) (*map[string]interface{}, string, error) { + u, err := url.Parse(uri) + if err == nil { + switch u.Scheme { + case "vmess": + return vmess(u.Host, i) + case "vless": + return vless(u, i) + case "trojan": + return trojan(u, i) + case "hy", "hysteria": + return hy(u, i) + case "hy2", "hysteria2": + return hy2(u, i) + case "anytls": + return anytls(u, i) + case "tuic": + return tuic(u, i) + case "ss", "shadowsocks": + return ss(u, i) + case "naive+https", "naive+quic", "http2": + return parseNaiveLink(u, i) + } + } + return nil, "", common.NewError("Unsupported link format") +} + +func vmess(data string, i int) (*map[string]interface{}, string, error) { + dataByte, err := B64StrToByte(data) + if err != nil { + return nil, "", err + } + var dataJson map[string]interface{} + err = json.Unmarshal(dataByte, &dataJson) + if err != nil { + return nil, "", err + } + transport := map[string]interface{}{} + tp_net, _ := dataJson["net"].(string) + tp_type, _ := dataJson["type"].(string) + tp_host, _ := dataJson["host"].(string) + tp_path, _ := dataJson["path"].(string) + switch strings.ToLower(tp_net) { + case "tcp", "": + if tp_type == "http" { + transport["type"] = tp_type + if len(tp_host) > 0 { + transport["host"] = strings.Split(tp_host, ",") + } + transport["path"] = tp_path + } + case "http", "h2": + transport["type"] = "http" + if len(tp_host) > 0 { + transport["host"] = strings.Split(tp_host, ",") + } + transport["path"] = tp_path + case "ws": + transport["type"] = tp_net + transport["path"] = tp_path + transport["early_data_header_name"] = "Sec-WebSocket-Protocol" + if len(tp_host) > 0 { + transport["headers"] = map[string]interface{}{ + "Host": tp_host, + } + } + case "quic": + transport["type"] = tp_net + case "grpc": + transport["type"] = tp_net + transport["service_name"] = tp_path + case "httpupgrade": + transport["type"] = tp_net + transport["path"] = tp_path + transport["host"] = tp_host + default: + return nil, "", common.NewError("Invalid vmess") + } + tls := map[string]interface{}{} + vmess_tls, _ := dataJson["tls"].(string) + if vmess_tls == "tls" { + tls["enabled"] = true + tls_sni, _ := dataJson["sni"].(string) + tls_alpn, _ := dataJson["alpn"].(string) + _, tls_insecure := dataJson["allowInsecure"] + tls_fp, _ := dataJson["fp"].(string) + if len(tls_sni) > 0 { + tls["server_name"] = tls_sni + } + if len(tls_alpn) > 0 { + tls["alpn"] = strings.Split(tls_alpn, ",") + } + if tls_insecure { + tls["insecure"] = true + } + if len(tls_fp) > 0 { + tls["utls"] = map[string]interface{}{ + "enabled": true, + "fingerprint": tls_fp, + } + } + } + tag, _ := dataJson["ps"].(string) + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, tag) + } + alter_id := 0 + if aid, ok := dataJson["aid"].(float64); ok { + alter_id = int(aid) + } + vmess := map[string]interface{}{ + "type": "vmess", + "tag": tag, + "server": dataJson["add"], + "server_port": dataJson["port"], + "uuid": dataJson["id"], + "security": "auto", + "alter_id": alter_id, + "tls": tls, + "transport": transport, + } + return &vmess, tag, err +} + +func vless(u *url.URL, i int) (*map[string]interface{}, string, error) { + query, _ := url.ParseQuery(u.RawQuery) + security := query.Get("security") + host, portStr, _ := net.SplitHostPort(u.Host) + port := 80 + if len(portStr) > 0 { + port, _ = strconv.Atoi(portStr) + } else { + if security == "tls" || security == "reality" { + port = 443 + } + } + tp_type := query.Get("type") + tag := u.Fragment + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, u.Fragment) + } + vless := map[string]interface{}{ + "type": "vless", + "tag": tag, + "server": host, + "server_port": port, + "uuid": u.User.Username(), + "flow": query.Get("flow"), + "tls": getTls(security, &query), + "transport": getTransport(tp_type, &query), + } + return &vless, tag, nil +} + +func trojan(u *url.URL, i int) (*map[string]interface{}, string, error) { + query, _ := url.ParseQuery(u.RawQuery) + security := query.Get("security") + host, portStr, _ := net.SplitHostPort(u.Host) + port := 80 + if len(portStr) > 0 { + port, _ = strconv.Atoi(portStr) + } else { + if security == "tls" || security == "reality" { + port = 443 + } + } + tp_type := query.Get("type") + tag := u.Fragment + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, u.Fragment) + } + trojan := map[string]interface{}{ + "type": "trojan", + "tag": tag, + "server": host, + "server_port": port, + "password": u.User.Username(), + "tls": getTls(security, &query), + "transport": getTransport(tp_type, &query), + } + return &trojan, tag, nil +} + +func hy(u *url.URL, i int) (*map[string]interface{}, string, error) { + query, _ := url.ParseQuery(u.RawQuery) + host, portStr, _ := net.SplitHostPort(u.Host) + port := 443 + if len(portStr) > 0 { + port, _ = strconv.Atoi(portStr) + } + + security := query.Get("security") + if len(security) == 0 { + security = "tls" + } + + tag := u.Fragment + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, u.Fragment) + } + hy := map[string]interface{}{ + "type": "hysteria", + "tag": tag, + "server": host, + "server_port": port, + "obfs": query.Get("obfsParam"), + "auth_str": query.Get("auth"), + "tls": getTls(security, &query), + } + down, _ := strconv.Atoi(query.Get("downmbps")) + up, _ := strconv.Atoi(query.Get("upmbps")) + recv_window_conn, _ := strconv.Atoi(query.Get("recv_window_conn")) + recv_window, _ := strconv.Atoi(query.Get("recv_window")) + if down > 0 { + hy["down_mbps"] = down + } + if up > 0 { + hy["up_mbps"] = up + } + if recv_window_conn > 0 { + hy["recv_window_conn"] = recv_window_conn + } + if recv_window > 0 { + hy["recv_window"] = recv_window + } + return &hy, tag, nil +} + +func hy2(u *url.URL, i int) (*map[string]interface{}, string, error) { + query, _ := url.ParseQuery(u.RawQuery) + host, portStr, _ := net.SplitHostPort(u.Host) + port := 443 + if len(portStr) > 0 { + port, _ = strconv.Atoi(portStr) + } + + security := query.Get("security") + if len(security) == 0 { + security = "tls" + } + + tag := u.Fragment + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, u.Fragment) + } + hy2 := map[string]interface{}{ + "type": "hysteria2", + "tag": tag, + "server": host, + "server_port": port, + "password": u.User.Username(), + "tls": getTls(security, &query), + } + down, _ := strconv.Atoi(query.Get("downmbps")) + up, _ := strconv.Atoi(query.Get("upmbps")) + obfs := query.Get("obfs") + mport := strings.ReplaceAll(query.Get("mport"), "-", ":") + fastopen := query.Get("fastopen") + if down > 0 { + hy2["down_mbps"] = down + } + if up > 0 { + hy2["up_mbps"] = up + } + if obfs == "salamander" { + hy2["obfs"] = map[string]interface{}{ + "type": "salamander", + "password": query.Get("obfs-password"), + } + } + if len(mport) > 0 { + hy2["server_ports"] = strings.Split(mport, ",") + } + if fastopen == "1" || fastopen == "true" { + hy2["fastopen"] = true + } + return &hy2, tag, nil +} + +func anytls(u *url.URL, i int) (*map[string]interface{}, string, error) { + query, _ := url.ParseQuery(u.RawQuery) + host, portStr, _ := net.SplitHostPort(u.Host) + port := 443 + if len(portStr) > 0 { + port, _ = strconv.Atoi(portStr) + } + + security := query.Get("security") + if len(security) == 0 { + security = "tls" + } + + tag := u.Fragment + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, u.Fragment) + } + anytls := map[string]interface{}{ + "type": "anytls", + "tag": tag, + "server": host, + "server_port": port, + "password": u.User.Username(), + "tls": getTls(security, &query), + } + return &anytls, tag, nil +} + +func tuic(u *url.URL, i int) (*map[string]interface{}, string, error) { + query, _ := url.ParseQuery(u.RawQuery) + host, portStr, _ := net.SplitHostPort(u.Host) + port := 443 + if len(portStr) > 0 { + port, _ = strconv.Atoi(portStr) + } + + security := query.Get("security") + if len(security) == 0 { + security = "tls" + } + + tag := u.Fragment + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, u.Fragment) + } + password, _ := u.User.Password() + tuic := map[string]interface{}{ + "type": "tuic", + "tag": tag, + "server": host, + "server_port": port, + "uuid": u.User.Username(), + "password": password, + "congestion_control": query.Get("congestion_control"), + "udp_relay_mode": query.Get("udp_relay_mode"), + "tls": getTls(security, &query), + } + return &tuic, tag, nil +} + +func ss(u *url.URL, i int) (*map[string]interface{}, string, error) { + query, _ := url.ParseQuery(u.RawQuery) + host, portStr, _ := net.SplitHostPort(u.Host) + port := 443 + if len(portStr) > 0 { + port, _ = strconv.Atoi(portStr) + } + method := u.User.Username() + password, ok := u.User.Password() + if !ok { + decrypted := StrOrBase64Encoded(method) + decrypted_arr := strings.Split(decrypted, ":") + if len(decrypted_arr) > 1 { + method = decrypted_arr[0] + password = strings.Join(decrypted_arr[1:], ":") + } else { + return nil, "", common.NewError("Unsupported shadowsocks") + } + } + + tag := u.Fragment + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, u.Fragment) + } + ss := map[string]interface{}{ + "type": "shadowsocks", + "tag": tag, + "server": host, + "server_port": port, + "method": method, + "password": password, + } + + v2ray_type := query.Get("type") + if len(v2ray_type) > 0 { + pl_arr := []string{} + host_header := query.Get("host") + if query.Get("security") == "tls" { + pl_arr = append(pl_arr, "tls") + } + if v2ray_type == "quic" { + pl_arr = append(pl_arr, "mode=quic") + } + if len(host_header) > 0 { + pl_arr = append(pl_arr, "host="+host_header) + } + ss["plugin"] = "v2ray-plugin" + ss["plugin_opts"] = strings.Join(pl_arr, ";") + } + plugin := query.Get("plugin") + if len(plugin) > 0 { + pl_arr := strings.Split(plugin, ";") + if len(pl_arr) > 0 { + ss["plugin"] = pl_arr[0] + ss["plugin_opts"] = strings.Join(pl_arr[1:], ";") + } + } + return &ss, tag, nil +} + +func parseNaiveLink(u *url.URL, i int) (*map[string]interface{}, string, error) { + var host, portStr, username, password string + var port int + + switch u.Scheme { + case "http2": + decoded := StrOrBase64Encoded(u.Hostname()) + if idx := strings.Index(decoded, "@"); idx != -1 { + userInfo := decoded[:idx] + hostPort := decoded[idx+1:] + if idx2 := strings.Index(userInfo, ":"); idx2 != -1 { + username = userInfo[:idx2] + password = userInfo[idx2+1:] + } else { + username = userInfo + } + host, portStr, _ = net.SplitHostPort(hostPort) + if portStr != "" { + port, _ = strconv.Atoi(portStr) + } else { + port = 443 + } + } else { + return nil, "", common.NewError("Invalid naive link (http2)") + } + case "naive+https", "naive+quic": + host, portStr, _ = net.SplitHostPort(u.Host) + if portStr != "" { + port, _ = strconv.Atoi(portStr) + } else { + port = 443 + } + if u.User != nil { + username = u.User.Username() + password, _ = u.User.Password() + } + default: + return nil, "", common.NewError("Unsupported naive scheme") + } + + tag := u.Fragment + if i > 0 { + tag = fmt.Sprintf("%d.%s", i, u.Fragment) + } + if tag == "" { + tag = fmt.Sprintf("naive-%d", i) + } + + naive := map[string]interface{}{ + "type": "naive", + "tag": tag, + "server": host, + "server_port": port, + "username": username, + "password": password, + "tls": map[string]interface{}{"enabled": true}, + } + + query := u.Query() + if peer := query.Get("peer"); peer != "" { + if tls, ok := naive["tls"].(map[string]interface{}); ok { + tls["server_name"] = peer + } + } + if insecure := query.Get("insecure"); insecure == "1" || insecure == "true" { + if tls, ok := naive["tls"].(map[string]interface{}); ok { + tls["insecure"] = true + } + } + if alpn := query.Get("alpn"); alpn != "" { + if tls, ok := naive["tls"].(map[string]interface{}); ok { + tls["alpn"] = strings.Split(alpn, ",") + } + } + if u.Scheme == "naive+quic" { + naive["quic"] = true + } + + return &naive, tag, nil +} + +func getTransport(tp_type string, q *url.Values) map[string]interface{} { + transport := map[string]interface{}{} + tp_host := q.Get("host") + tp_path := q.Get("path") + switch strings.ToLower(tp_type) { + case "tcp", "": + if q.Get("headerType") == "http" { + transport["type"] = "http" + if len(tp_host) > 0 { + transport["host"] = strings.Split(tp_host, ",") + } + transport["path"] = tp_path + } + case "http", "h2": + transport["type"] = "http" + if len(tp_host) > 0 { + transport["host"] = strings.Split(tp_host, ",") + } + transport["path"] = tp_path + case "ws": + transport["type"] = "ws" + transport["path"] = tp_path + if len(tp_host) > 0 { + transport["headers"] = map[string]interface{}{ + "Host": tp_host, + } + } + case "quic": + transport["type"] = "quic" + case "grpc": + transport["type"] = "grpc" + transport["service_name"] = q.Get("serviceName") + case "httpupgrade": + transport["type"] = "httpupgrade" + transport["path"] = tp_path + transport["host"] = tp_host + } + return transport +} + +func getTls(security string, q *url.Values) map[string]interface{} { + tls := map[string]interface{}{} + tls_fp := q.Get("fp") + tls_sni := q.Get("sni") + tls_allow_insecure := q.Get("allowInsecure") + tls_insecure := q.Get("insecure") + tls_alpn := q.Get("alpn") + tls_ech := q.Get("ech") + disable_sni := q.Get("disable_sni") + switch security { + case "tls": + tls["enabled"] = true + case "reality": + tls["enabled"] = true + tls["reality"] = map[string]interface{}{ + "enabled": true, + "public_key": q.Get("pbk"), + "short_id": q.Get("sid"), + } + } + if len(tls_sni) > 0 { + tls["server_name"] = tls_sni + } + if len(tls_alpn) > 0 { + tls["alpn"] = strings.Split(tls_alpn, ",") + } + if tls_insecure == "1" || tls_insecure == "true" || tls_allow_insecure == "1" || tls_allow_insecure == "true" { + tls["insecure"] = true + } + if len(tls_fp) > 0 { + tls["utls"] = map[string]interface{}{ + "enabled": true, + "fingerprint": tls_fp, + } + } + if len(tls_ech) > 0 { + tls["ech"] = map[string]interface{}{ + "enabled": true, + "config": []string{ + tls_ech, + }, + } + } + if disable_sni == "1" || disable_sni == "true" { + tls["disable_sni"] = true + } + return tls +} diff --git a/util/outJson.go b/util/outJson.go new file mode 100644 index 0000000..5063d46 --- /dev/null +++ b/util/outJson.go @@ -0,0 +1,237 @@ +package util + +import ( + "encoding/json" + + "github.com/alireza0/s-ui/util/common" + + "github.com/alireza0/s-ui/database/model" +) + +// Fill Inbound's out_json +func FillOutJson(i *model.Inbound, hostname string) error { + switch i.Type { + case "direct", "tun", "redirect", "tproxy": + return nil + } + var outJson map[string]interface{} + err := json.Unmarshal(i.OutJson, &outJson) + if err != nil { + return err + } + + if outJson == nil { + outJson = make(map[string]interface{}) + } + + if i.TlsId > 0 { + addTls(&outJson, i.Tls) + } else { + delete(outJson, "tls") + } + + inbound, err := i.MarshalFull() + if err != nil { + return err + } + + outJson["type"] = i.Type + outJson["tag"] = i.Tag + outJson["server"] = hostname + outJson["server_port"] = (*inbound)["listen_port"] + + switch i.Type { + case "http", "socks", "mixed", "anytls": + case "naive": + naiveOut(&outJson, *inbound) + case "shadowsocks": + shadowsocksOut(&outJson, *inbound) + case "shadowtls": + shadowTlsOut(&outJson, *inbound) + case "hysteria": + hysteriaOut(&outJson, *inbound) + case "hysteria2": + hysteria2Out(&outJson, *inbound) + case "tuic": + tuicOut(&outJson, *inbound) + case "vless": + vlessOut(&outJson, *inbound) + case "trojan": + trojanOut(&outJson, *inbound) + case "vmess": + vmessOut(&outJson, *inbound) + default: + for key := range outJson { + delete(outJson, key) + } + } + + i.OutJson, err = json.MarshalIndent(outJson, "", " ") + if err != nil { + return err + } + + return nil +} + +// addTls function +func addTls(out *map[string]interface{}, tls *model.Tls) { + var tlsServer, tlsConfig map[string]interface{} + err := json.Unmarshal(tls.Server, &tlsServer) + if err != nil { + return + } + err = json.Unmarshal(tls.Client, &tlsConfig) + if err != nil { + return + } + + if enabled, ok := tlsServer["enabled"]; ok { + tlsConfig["enabled"] = enabled + } + if serverName, ok := tlsServer["server_name"]; ok { + tlsConfig["server_name"] = serverName + } + if alpn, ok := tlsServer["alpn"]; ok { + tlsConfig["alpn"] = alpn + } + if minVersion, ok := tlsServer["min_version"]; ok { + tlsConfig["min_version"] = minVersion + } + if maxVersion, ok := tlsServer["max_version"]; ok { + tlsConfig["max_version"] = maxVersion + } + if certificate, ok := tlsServer["certificate"]; ok { + tlsConfig["certificate"] = certificate + } + if cipherSuites, ok := tlsServer["cipher_suites"]; ok { + tlsConfig["cipher_suites"] = cipherSuites + } + if reality, ok := tlsServer["reality"].(map[string]interface{}); ok && reality["enabled"].(bool) { + realityConfig := tlsConfig["reality"].(map[string]interface{}) + realityConfig["enabled"] = true + if shortIDs, ok := reality["short_id"].([]interface{}); ok && len(shortIDs) > 0 { + realityConfig["short_id"] = shortIDs[common.RandomInt(len(shortIDs))] + } + tlsConfig["reality"] = realityConfig + } + if ech, ok := tlsServer["ech"].(map[string]interface{}); ok && ech["enabled"].(bool) { + echConfig := tlsConfig["ech"].(map[string]interface{}) + echConfig["enabled"] = true + echConfig["pq_signature_schemes_enabled"] = ech["pq_signature_schemes_enabled"] + echConfig["dynamic_record_sizing_disabled"] = ech["dynamic_record_sizing_disabled"] + tlsConfig["ech"] = echConfig + } + + (*out)["tls"] = tlsConfig +} + +func naiveOut(out *map[string]interface{}, inbound map[string]interface{}) { + if quic_congestion_control, ok := inbound["quic_congestion_control"].(string); ok { + (*out)["quic"] = true + switch quic_congestion_control { + case "bbr_standard": + (*out)["quic_congestion_control"] = "bbr" + case "bbr2_variant": + (*out)["quic_congestion_control"] = "bbr2" + default: + (*out)["quic_congestion_control"] = quic_congestion_control + } + } + +} + +func shadowsocksOut(out *map[string]interface{}, inbound map[string]interface{}) { + if method, ok := inbound["method"].(string); ok { + (*out)["method"] = method + } +} + +func shadowTlsOut(out *map[string]interface{}, inbound map[string]interface{}) { + if version, ok := inbound["version"].(float64); ok && int(version) == 3 { + (*out)["version"] = 3 + } else { + for key := range *out { + delete(*out, key) + } + } + (*out)["tls"] = map[string]interface{}{"enabled": true} +} + +func hysteriaOut(out *map[string]interface{}, inbound map[string]interface{}) { + delete(*out, "down_mbps") + delete(*out, "up_mbps") + delete(*out, "obfs") + delete(*out, "recv_window_conn") + delete(*out, "disable_mtu_discovery") + + if upMbps, ok := inbound["down_mbps"]; ok { + (*out)["up_mbps"] = upMbps + } + if downMbps, ok := inbound["up_mbps"]; ok { + (*out)["down_mbps"] = downMbps + } + if obfs, ok := inbound["obfs"]; ok { + (*out)["obfs"] = obfs + } + if recvWindow, ok := inbound["recv_window_conn"]; ok { + (*out)["recv_window_conn"] = recvWindow + } + if disableMTU, ok := inbound["disable_mtu_discovery"]; ok { + (*out)["disable_mtu_discovery"] = disableMTU + } +} + +func hysteria2Out(out *map[string]interface{}, inbound map[string]interface{}) { + delete(*out, "down_mbps") + delete(*out, "up_mbps") + delete(*out, "obfs") + + if upMbps, ok := inbound["down_mbps"]; ok { + (*out)["up_mbps"] = upMbps + } + if downMbps, ok := inbound["up_mbps"]; ok { + (*out)["down_mbps"] = downMbps + } + if obfs, ok := inbound["obfs"]; ok { + (*out)["obfs"] = obfs + } +} + +func tuicOut(out *map[string]interface{}, inbound map[string]interface{}) { + delete(*out, "zero_rtt_handshake") + delete(*out, "heartbeat") + if congestionControl, ok := inbound["congestion_control"].(string); ok { + (*out)["congestion_control"] = congestionControl + } else { + (*out)["congestion_control"] = "cubic" + } + if zeroRTT, ok := inbound["zero_rtt_handshake"].(bool); ok { + (*out)["zero_rtt_handshake"] = zeroRTT + } + if heartbeat, ok := inbound["heartbeat"]; ok { + (*out)["heartbeat"] = heartbeat + } +} + +func vlessOut(out *map[string]interface{}, inbound map[string]interface{}) { + delete(*out, "transport") + if transport, ok := inbound["transport"]; ok { + (*out)["transport"] = transport + } +} + +func trojanOut(out *map[string]interface{}, inbound map[string]interface{}) { + delete(*out, "transport") + if transport, ok := inbound["transport"]; ok { + (*out)["transport"] = transport + } +} + +func vmessOut(out *map[string]interface{}, inbound map[string]interface{}) { + (*out)["alter_id"] = 0 + delete(*out, "transport") + if transport, ok := inbound["transport"]; ok { + (*out)["transport"] = transport + } +} diff --git a/util/subInfo.go b/util/subInfo.go new file mode 100644 index 0000000..b46d944 --- /dev/null +++ b/util/subInfo.go @@ -0,0 +1,15 @@ +package util + +import ( + "fmt" + + "github.com/alireza0/s-ui/database/model" +) + +func GetHeaders(client *model.Client, updateInterval int) []string { + var headers []string + headers = append(headers, fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", client.Up, client.Down, client.Volume, client.Expiry)) + headers = append(headers, fmt.Sprintf("%d", updateInterval)) + headers = append(headers, client.Name) + return headers +} diff --git a/util/subToJson.go b/util/subToJson.go new file mode 100644 index 0000000..88cb31d --- /dev/null +++ b/util/subToJson.go @@ -0,0 +1,97 @@ +package util + +import ( + "crypto/tls" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" +) + +func GetExternalLink(url string) string { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + + client := &http.Client{Transport: tr} + + response, err := client.Get(url) + if err != nil { + logger.Warning("sub: Error making HTTP request:", err) + return "" + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + logger.Warning("sub: Error reading response body:", err) + return "" + } + + data := StrOrBase64Encoded(string(body)) + return data +} + +func GetExternalSub(url string) ([]map[string]interface{}, error) { + var err error + var result []map[string]interface{} + + if len(url) == 0 { + return nil, common.NewError("no url") + } + + data := GetExternalLink(url) + if len(data) == 0 { + return nil, common.NewError("no result") + } + + // if the data is a JSON object + if strings.HasPrefix(data, "{") && strings.HasSuffix(data, "}") { + var jsonData map[string]interface{} + err = json.Unmarshal([]byte(data), &jsonData) + if err != nil { + logger.Warning("sub: Error unmarshalling JSON:", err) + return nil, err + } + outbounds, ok := jsonData["outbounds"].([]any) + if !ok { + logger.Warning("sub: Error getting outbounds:", err) + return nil, err + } + for _, outbound := range outbounds { + outboundMap, ok := outbound.(map[string]interface{}) + if ok && len(outboundMap) > 0 { + oType, _ := outboundMap["type"].(string) + switch oType { + case "urltest": + case "direct": + case "selector": + case "block": + continue + default: + result = append(result, outboundMap) + } + } + } + if len(result) == 0 { + return nil, common.NewError("no result") + } + return result, nil + } else { + // if data is a text + links := strings.Split(data, "\n") + for _, link := range links { + linkToJson, _, err := GetOutbound(link, 0) + if err == nil { + result = append(result, *linkToJson) + } + } + } + if len(result) == 0 { + return nil, common.NewError("no result") + } + return result, nil +} diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..95543b4 --- /dev/null +++ b/web/web.go @@ -0,0 +1,229 @@ +package web + +import ( + "context" + "crypto/tls" + "embed" + "html/template" + "io" + "io/fs" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/alireza0/s-ui/api" + "github.com/alireza0/s-ui/config" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/middleware" + "github.com/alireza0/s-ui/network" + "github.com/alireza0/s-ui/service" + + "github.com/gin-contrib/gzip" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +//go:embed * +var content embed.FS + +type Server struct { + httpServer *http.Server + listener net.Listener + ctx context.Context + cancel context.CancelFunc + settingService service.SettingService +} + +func NewServer() *Server { + ctx, cancel := context.WithCancel(context.Background()) + return &Server{ + ctx: ctx, + cancel: cancel, + } +} + +func (s *Server) initRouter() (*gin.Engine, error) { + if config.IsDebug() { + gin.SetMode(gin.DebugMode) + } else { + gin.DefaultWriter = io.Discard + gin.DefaultErrorWriter = io.Discard + gin.SetMode(gin.ReleaseMode) + } + + engine := gin.Default() + + // Load the HTML template + t := template.New("").Funcs(engine.FuncMap) + template, err := t.ParseFS(content, "html/index.html") + if err != nil { + return nil, err + } + engine.SetHTMLTemplate(template) + + base_url, err := s.settingService.GetWebPath() + if err != nil { + return nil, err + } + + webDomain, err := s.settingService.GetWebDomain() + if err != nil { + return nil, err + } + + if webDomain != "" { + engine.Use(middleware.DomainValidator(webDomain)) + } + + secret, err := s.settingService.GetSecret() + if err != nil { + return nil, err + } + + engine.Use(gzip.Gzip(gzip.DefaultCompression)) + assetsBasePath := base_url + "assets/" + + store := cookie.NewStore(secret) + engine.Use(sessions.Sessions("s-ui", store)) + + engine.Use(func(c *gin.Context) { + uri := c.Request.RequestURI + if strings.HasPrefix(uri, assetsBasePath) { + c.Header("Cache-Control", "max-age=31536000") + } + }) + + // Serve the assets folder + assetsFS, err := fs.Sub(content, "html/assets") + if err != nil { + panic(err) + } + + engine.StaticFS(assetsBasePath, http.FS(assetsFS)) + + group_apiv2 := engine.Group(base_url + "apiv2") + apiv2 := api.NewAPIv2Handler(group_apiv2) + + group_api := engine.Group(base_url + "api") + api.NewAPIHandler(group_api, apiv2) + + // Serve index.html as the entry point + // Handle all other routes by serving index.html + engine.NoRoute(func(c *gin.Context) { + if c.Request.URL.Path == strings.TrimSuffix(base_url, "/") { + c.Redirect(http.StatusTemporaryRedirect, base_url) + return + } + if !strings.HasPrefix(c.Request.URL.Path, base_url) { + c.String(404, "") + return + } + if c.Request.URL.Path != base_url+"login" && !api.IsLogin(c) { + c.Redirect(http.StatusTemporaryRedirect, base_url+"login") + return + } + if c.Request.URL.Path == base_url+"login" && api.IsLogin(c) { + c.Redirect(http.StatusTemporaryRedirect, base_url) + return + } + c.HTML(http.StatusOK, "index.html", gin.H{"BASE_URL": base_url}) + }) + + return engine, nil +} + +func (s *Server) Start() (err error) { + //This is an anonymous function, no function name + defer func() { + if err != nil { + s.Stop() + } + }() + + engine, err := s.initRouter() + if err != nil { + return err + } + + certFile, err := s.settingService.GetCertFile() + if err != nil { + return err + } + keyFile, err := s.settingService.GetKeyFile() + if err != nil { + return err + } + listen, err := s.settingService.GetListen() + if err != nil { + return err + } + port, err := s.settingService.GetPort() + if err != nil { + return err + } + listenAddr := net.JoinHostPort(listen, strconv.Itoa(port)) + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + return err + } + if certFile != "" || keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + listener.Close() + return err + } + c := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + listener = network.NewAutoHttpsListener(listener) + listener = tls.NewListener(listener, c) + } + + if certFile != "" || keyFile != "" { + logger.Info("web server run https on", listener.Addr()) + } else { + logger.Info("web server run http on", listener.Addr()) + } + s.listener = listener + + s.httpServer = &http.Server{ + Handler: engine, + } + + go func() { + s.httpServer.Serve(listener) + }() + + return nil +} + +func (s *Server) Stop() error { + var err error + if s.httpServer != nil { + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 30*time.Second) + err = s.httpServer.Shutdown(shutdownCtx) + cancelShutdown() + if err != nil { + s.cancel() + if s.listener != nil { + _ = s.listener.Close() + } + return err + } + } else if s.listener != nil { + err = s.listener.Close() + if err != nil { + s.cancel() + return err + } + } + s.cancel() + return nil +} + +func (s *Server) GetCtx() context.Context { + return s.ctx +} diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..00c5a90 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,23 @@ +# Windows Files + +This directory contains all Windows-specific files for S-UI. + +## Available Files: + +- **s-ui-windows.xml**: Windows Service configuration +- **install-windows.bat**: Installation script +- **s-ui-windows.bat**: Control panel +- **uninstall-windows.bat**: Uninstallation script +- **build-windows.bat**: Simple build script for CMD +- **build-windows.ps1**: Advanced build script for PowerShell + +## Usage: + +To install S-UI on Windows: +1. Run `install-windows.bat` as Administrator +2. Follow the installation wizard +3. Use `s-ui-windows.bat` for management + +To build from source: +- With CMD: `build-windows.bat` +- With PowerShell: `.\build-windows.ps1` diff --git a/windows/build-windows.bat b/windows/build-windows.bat new file mode 100644 index 0000000..0ec64b5 --- /dev/null +++ b/windows/build-windows.bat @@ -0,0 +1,73 @@ +@echo off +setlocal enabledelayedexpansion + +echo Building S-UI for Windows... + +cd /d "%~dp0" + +REM Check if Go is installed +go version >nul 2>&1 +if errorlevel 1 ( + echo Error: Go is not installed or not in PATH + echo Please install Go from https://golang.org/dl/ + pause + exit /b 1 +) + +REM Check if Node.js is installed +node --version >nul 2>&1 +if errorlevel 1 ( + echo Error: Node.js is not installed or not in PATH + echo Please install Node.js from https://nodejs.org/ + pause + exit /b 1 +) + +echo Building frontend... +cd frontend +call npm install +if errorlevel 1 ( + echo Error: Failed to install frontend dependencies + pause + exit /b 1 +) + +call npm run build +if errorlevel 1 ( + echo Error: Failed to build frontend + pause + exit /b 1 +) + +cd .. + +echo Creating web/html directory... +if not exist "web\html" mkdir "web\html" + +echo Copying frontend build files... +xcopy "frontend\dist\*" "web\html\" /E /Y /Q + +echo Building backend... +set CGO_ENABLED=1 +set GOOS=windows +set GOARCH=amd64 + +REM Try to build with CGO first +go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_tailscale" -o sui.exe main.go +if errorlevel 1 ( + echo Warning: CGO build failed, trying without CGO... + set CGO_ENABLED=0 + go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_tailscale" -o sui.exe main.go + if errorlevel 1 ( + echo Error: Failed to build backend + pause + exit /b 1 + ) + echo Built without CGO (some features may be limited) +) else ( + echo Built with CGO +) + +echo Build completed successfully! +echo Output: sui.exe +pause diff --git a/windows/build-windows.ps1 b/windows/build-windows.ps1 new file mode 100644 index 0000000..6435d41 --- /dev/null +++ b/windows/build-windows.ps1 @@ -0,0 +1,138 @@ +# PowerShell script for building S-UI on Windows +param( + [string]$Architecture = "amd64", + [switch]$NoCGO, + [switch]$Help +) + +if ($Help) { + Write-Host "Usage: .\build-windows.ps1 [-Architecture ] [-NoCGO] [-Help]" + Write-Host "Architectures: amd64, 386, arm64" + Write-Host "Examples:" + Write-Host " .\build-windows.ps1 # Build for amd64 with CGO" + Write-Host " .\build-windows.ps1 -Architecture 386 # Build for 32-bit Windows" + Write-Host " .\build-windows.ps1 -NoCGO # Build without CGO" + exit 0 +} + +Write-Host "Building S-UI for Windows ($Architecture)..." -ForegroundColor Green + +# Check if Go is installed +try { + $goVersion = go version 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Go not found" + } + Write-Host "Go version: $goVersion" -ForegroundColor Green +} catch { + Write-Host "Error: Go is not installed or not in PATH" -ForegroundColor Red + Write-Host "Please install Go from https://golang.org/dl/" -ForegroundColor Yellow + Read-Host "Press Enter to exit" + exit 1 +} + +# Check if Node.js is installed +try { + $nodeVersion = node --version 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Node.js not found" + } + Write-Host "Node.js version: $nodeVersion" -ForegroundColor Green +} catch { + Write-Host "Error: Node.js is not installed or not in PATH" -ForegroundColor Red + Write-Host "Please install Node.js from https://nodejs.org/" -ForegroundColor Yellow + Read-Host "Press Enter to exit" + exit 1 +} + +# Build frontend +Write-Host "Building frontend..." -ForegroundColor Yellow +Push-Location frontend + +try { + Write-Host "Installing dependencies..." -ForegroundColor Cyan + npm install + if ($LASTEXITCODE -ne 0) { + throw "Failed to install frontend dependencies" + } + + Write-Host "Building frontend..." -ForegroundColor Cyan + npm run build + if ($LASTEXITCODE -ne 0) { + throw "Failed to build frontend" + } +} catch { + Write-Host "Error: $_" -ForegroundColor Red + Pop-Location + Read-Host "Press Enter to exit" + exit 1 +} + +Pop-Location + +# Create web/html directory +Write-Host "Creating web/html directory..." -ForegroundColor Yellow +if (!(Test-Path "web\html")) { + New-Item -ItemType Directory -Path "web\html" -Force | Out-Null +} + +# Copy frontend build files +Write-Host "Copying frontend build files..." -ForegroundColor Yellow +Copy-Item "frontend\dist\*" "web\html\" -Recurse -Force + +# Build backend +Write-Host "Building backend..." -ForegroundColor Yellow + +# Set environment variables +$env:GOOS = "windows" +$env:GOARCH = $Architecture + +if ($NoCGO) { + $env:CGO_ENABLED = "0" + Write-Host "Building without CGO..." -ForegroundColor Yellow +} else { + $env:CGO_ENABLED = "1" + Write-Host "Building with CGO..." -ForegroundColor Yellow +} + +# Build command +$buildCmd = "go build -ldflags `"-w -s`" -tags `"with_quic,with_grpc,with_utls,with_acme,with_gvisor,with_tailscale`" -o sui.exe main.go" + +try { + Invoke-Expression $buildCmd + if ($LASTEXITCODE -ne 0) { + if (!$NoCGO) { + Write-Host "CGO build failed, trying without CGO..." -ForegroundColor Yellow + $env:CGO_ENABLED = "0" + Invoke-Expression $buildCmd + if ($LASTEXITCODE -ne 0) { + throw "Failed to build backend even without CGO" + } + Write-Host "Built without CGO (some features may be limited)" -ForegroundColor Yellow + } else { + throw "Failed to build backend" + } + } else { + if ($env:CGO_ENABLED -eq "1") { + Write-Host "Built successfully with CGO" -ForegroundColor Green + } else { + Write-Host "Built successfully without CGO" -ForegroundColor Green + } + } +} catch { + Write-Host "Error: $_" -ForegroundColor Red + Read-Host "Press Enter to exit" + exit 1 +} + +Write-Host "Build completed successfully!" -ForegroundColor Green +Write-Host "Output: sui.exe" -ForegroundColor Green + +# Show file info +if (Test-Path "sui.exe") { + $fileInfo = Get-Item "sui.exe" + Write-Host "File size: $([math]::Round($fileInfo.Length / 1MB, 2)) MB" -ForegroundColor Cyan + Write-Host "Created: $($fileInfo.CreationTime)" -ForegroundColor Cyan +} + +Read-Host "Press Enter to exit" diff --git a/windows/install-windows.bat b/windows/install-windows.bat new file mode 100644 index 0000000..10bf1ea --- /dev/null +++ b/windows/install-windows.bat @@ -0,0 +1,195 @@ +@echo off +setlocal enabledelayedexpansion + +echo ======================================== +echo S-UI Windows Installer +echo ======================================== + +REM Check if running as Administrator +net session >nul 2>&1 +if %errorLevel% neq 0 ( + echo Error: This script must be run as Administrator + echo Right-click on this file and select "Run as administrator" + pause + exit /b 1 +) + +cd /d "%~dp0" +REM Set installation directory +set "INSTALL_DIR=C:\Program Files\s-ui" +set "SERVICE_NAME=s-ui" + +echo Installing S-UI to: %INSTALL_DIR% + +REM Create installation directory +if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%" +if not exist "%INSTALL_DIR%\db" mkdir "%INSTALL_DIR%\db" +if not exist "%INSTALL_DIR%\logs" mkdir "%INSTALL_DIR%\logs" +if not exist "%INSTALL_DIR%\cert" mkdir "%INSTALL_DIR%\cert" + +REM Copy files +echo Copying files... +copy "sui.exe" "%INSTALL_DIR%\" >nul +copy "s-ui-windows.xml" "%INSTALL_DIR%\" >nul +copy "s-ui-windows.bat" "%INSTALL_DIR%\" >nul + +REM Check if WinSW is available +set "WINSW_PATH=%INSTALL_DIR%\winsw.exe" +if not exist "%WINSW_PATH%" ( + echo Downloading WinSW... + powershell -Command "& {Invoke-WebRequest -Uri 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe' -OutFile '%WINSW_PATH%'}" + if exist "%WINSW_PATH%" ( + echo WinSW downloaded successfully + ) else ( + echo Warning: Failed to download WinSW. Service installation will be skipped. + echo You can manually download WinSW from: https://github.com/winsw/winsw/releases + ) +) + +REM Install Windows Service +if exist "%WINSW_PATH%" ( + echo Installing Windows Service... + cd /d "%INSTALL_DIR%" + copy "winsw.exe" "s-ui-service.exe" >nul + copy "s-ui-windows.xml" "s-ui-service.xml" >nul + + REM Install service + s-ui-service.exe install + if %errorLevel% equ 0 ( + echo Service installed successfully + ) else ( + echo Warning: Failed to install service. You can install it manually later. + ) +) + +REM Run migration +echo Running database migration... +cd /d "%INSTALL_DIR%" +sui.exe migrate +if %errorLevel% equ 0 ( + echo Migration completed successfully +) else ( + echo Warning: Migration failed or database is new +) + +REM Get network configuration +echo. +echo ======================================== +echo Network Configuration +echo ======================================== + +REM Get local IP addresses +echo Available IP addresses: +for /f "tokens=2 delims=:" %%i in ('ipconfig ^| findstr /i "IPv4"') do ( + echo %%i +) + +REM Get panel configuration +echo. +set /p panel_port="Enter panel port (default: 2095): " +if "%panel_port%"=="" set "panel_port=2095" + +set /p panel_path="Enter panel path (default: /app/): " +if "%panel_path%"=="" set "panel_path=/app/" + +set /p sub_port="Enter subscription port (default: 2096): " +if "%sub_port%"=="" set "sub_port=2096" + +set /p sub_path="Enter subscription path (default: /sub/): " +if "%sub_path%"=="" set "sub_path=/sub/" + +REM Apply settings +echo. +echo Applying settings... +cd /d "%INSTALL_DIR%" +sui.exe setting -port %panel_port% -path "%panel_path%" -subPort %sub_port% -subPath "%sub_path%" + +REM Get admin credentials +echo. +echo ======================================== +echo Admin Configuration +echo ======================================== + +set /p admin_username="Enter admin username (default: admin): " +if "%admin_username%"=="" set "admin_username=admin" + +set /p admin_password="Enter admin password: " +if "%admin_password%"=="" ( + echo Error: Password cannot be empty + pause + exit /b 1 +) + +REM Set admin credentials +echo Setting admin credentials... +sui.exe admin -username "%admin_username%" -password "%admin_password%" + +REM Start service +echo Starting S-UI service... +net start %SERVICE_NAME% +if %errorLevel% equ 0 ( + echo Service started successfully +) else ( + echo Warning: Failed to start service. You can start it manually later. +) + +REM Create desktop shortcut +echo Creating desktop shortcut... +set "DESKTOP=%USERPROFILE%\Desktop" +if exist "%DESKTOP%" ( + powershell -Command "& {$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%DESKTOP%\S-UI.lnk'); $Shortcut.TargetPath = '%INSTALL_DIR%\s-ui-windows.bat'; $Shortcut.WorkingDirectory = '%INSTALL_DIR%'; $Shortcut.Description = 'S-UI Control Panel'; $Shortcut.Save()}" + echo Desktop shortcut created +) + +REM Create Start Menu shortcut +echo Creating Start Menu shortcut... +set "START_MENU=%APPDATA%\Microsoft\Windows\Start Menu\Programs" +if exist "%START_MENU%" ( + if not exist "%START_MENU%\S-UI" mkdir "%START_MENU%\S-UI" + powershell -Command "& {$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%START_MENU%\S-UI\S-UI Control Panel.lnk'); $Shortcut.TargetPath = '%INSTALL_DIR%\s-ui-windows.bat'; $Shortcut.WorkingDirectory = '%INSTALL_DIR%'; $Shortcut.Description = 'S-UI Control Panel'; $Shortcut.Save()}" + echo Start Menu shortcut created +) + +REM Set permissions +echo Setting permissions... +icacls "%INSTALL_DIR%" /grant "Users:(OI)(CI)RX" /T >nul +icacls "%INSTALL_DIR%\db" /grant "Users:(OI)(CI)F" /T >nul +icacls "%INSTALL_DIR%\logs" /grant "Users:(OI)(CI)F" /T >nul + +REM Create environment variable +echo Setting environment variable... +setx SUI_HOME "%INSTALL_DIR%" /M >nul + +REM Show final configuration +echo. +echo ======================================== +echo Installation completed successfully! +echo ======================================== +echo. +echo S-UI has been installed to: %INSTALL_DIR% +echo. +echo Configuration: +echo Panel Port: %panel_port% +echo Panel Path: %panel_path% +echo Subscription Port: %sub_port% +echo Subscription Path: %sub_path% +echo Admin Username: %admin_username% +echo. +echo Access URLs: +for /f "tokens=2 delims=:" %%i in ('ipconfig ^| findstr /i "IPv4"') do ( + set "ip=%%i" + set "ip=!ip: =!" + echo Panel: http://!ip!:%panel_port%%panel_path% + echo Subscription: http://!ip!:%sub_port%%sub_path% +) +echo. +echo Service name: %SERVICE_NAME% +echo. +echo Useful commands: +echo net start %SERVICE_NAME% - Start the service +echo net stop %SERVICE_NAME% - Stop the service +echo sc query %SERVICE_NAME% - Check service status +echo. +echo You can also use the desktop shortcut or Start Menu item. +echo. +pause diff --git a/windows/s-ui-windows.bat b/windows/s-ui-windows.bat new file mode 100644 index 0000000..61448fa --- /dev/null +++ b/windows/s-ui-windows.bat @@ -0,0 +1,237 @@ +@echo off +setlocal enabledelayedexpansion + +REM S-UI Windows Control Script +REM This script provides a menu-driven interface for managing S-UI on Windows + +cd /d "%~dp0" +set "SERVICE_NAME=s-ui" +set "INSTALL_DIR=%SUI_HOME%" +if "%INSTALL_DIR%"=="" set "INSTALL_DIR=C:\Program Files\s-ui" + +:menu +cls +echo ======================================== +echo S-UI Windows Control Panel +echo ======================================== +echo. +echo Current directory: %INSTALL_DIR% +echo. +echo 1. Start S-UI Service +echo 2. Stop S-UI Service +echo 3. Restart S-UI Service +echo 4. Check Service Status +echo 5. View Service Logs +echo 6. Open Panel in Browser +echo 7. Run S-UI Manually +echo 8. Install/Uninstall Service +echo 9. Open Installation Directory +echo 10. Show Configuration +echo 11. Show Access URLs +echo 0. Exit +echo. +echo ======================================== + +set /p choice="Please select an option [0-11]: " + +if "%choice%"=="1" goto start_service +if "%choice%"=="2" goto stop_service +if "%choice%"=="3" goto restart_service +if "%choice%"=="4" goto check_status +if "%choice%"=="5" goto view_logs +if "%choice%"=="6" goto open_panel +if "%choice%"=="7" goto run_manual +if "%choice%"=="8" goto service_management +if "%choice%"=="9" goto open_directory +if "%choice%"=="10" goto show_config +if "%choice%"=="11" goto show_urls +if "%choice%"=="0" goto exit +goto invalid_choice + +:start_service +echo Starting S-UI service... +net start %SERVICE_NAME% +if %errorLevel% equ 0 ( + echo Service started successfully! +) else ( + echo Failed to start service. Error code: %errorLevel% +) +pause +goto menu + +:stop_service +echo Stopping S-UI service... +net stop %SERVICE_NAME% +if %errorLevel% equ 0 ( + echo Service stopped successfully! +) else ( + echo Failed to stop service. Error code: %errorLevel% +) +pause +goto menu + +:restart_service +echo Restarting S-UI service... +net stop %SERVICE_NAME% >nul 2>&1 +timeout /t 2 /nobreak >nul +net start %SERVICE_NAME% +if %errorLevel% equ 0 ( + echo Service restarted successfully! +) else ( + echo Failed to restart service. Error code: %errorLevel% +) +pause +goto menu + +:check_status +echo Checking S-UI service status... +sc query %SERVICE_NAME% +echo. +echo Service status details: +for /f "tokens=3 delims=: " %%i in ('sc query %SERVICE_NAME% ^| find "STATE"') do ( + echo Current state: %%i +) +pause +goto menu + +:view_logs +echo Opening S-UI logs... +if exist "%INSTALL_DIR%\logs" ( + start "" "%INSTALL_DIR%\logs" +) else ( + echo Logs directory not found: %INSTALL_DIR%\logs +) +pause +goto menu + +:open_panel +echo Opening S-UI panel in browser... +start http://localhost:2095 +echo Panel opened in default browser. +pause +goto menu + +:run_manual +echo Running S-UI manually... +if exist "%INSTALL_DIR%\sui.exe" ( + cd /d "%INSTALL_DIR%" + echo Starting S-UI in current window... + echo Press Ctrl+C to stop + echo. + sui.exe +) else ( + echo S-UI executable not found: %INSTALL_DIR%\sui.exe + echo Please run the installer first. +) +pause +goto menu + +:service_management +cls +echo ======================================== +echo Service Management +echo ======================================== +echo. +echo 1. Install Windows Service +echo 2. Uninstall Windows Service +echo 3. Back to Main Menu +echo. +set /p service_choice="Select option [1-3]: " + +if "%service_choice%"=="1" goto install_service +if "%service_choice%"=="2" goto uninstall_service +if "%service_choice%"=="3" goto menu +goto invalid_choice + +:install_service +echo Installing Windows Service... +if exist "%INSTALL_DIR%\s-ui-service.exe" ( + cd /d "%INSTALL_DIR%" + s-ui-service.exe install + if %errorLevel% equ 0 ( + echo Service installed successfully! + echo Starting service... + net start %SERVICE_NAME% + ) else ( + echo Failed to install service. Error code: %errorLevel% + ) +) else ( + echo Service wrapper not found. Please run the installer first. +) +pause +goto service_management + +:uninstall_service +echo Uninstalling Windows Service... +if exist "%INSTALL_DIR%\s-ui-service.exe" ( + cd /d "%INSTALL_DIR%" + net stop %SERVICE_NAME% >nul 2>&1 + s-ui-service.exe uninstall + if %errorLevel% equ 0 ( + echo Service uninstalled successfully! + ) else ( + echo Failed to uninstall service. Error code: %errorLevel% + ) +) else ( + echo Service wrapper not found. +) +pause +goto service_management + +:open_directory +echo Opening installation directory... +if exist "%INSTALL_DIR%" ( + start "" "%INSTALL_DIR%" +) else ( + echo Installation directory not found: %INSTALL_DIR% +) +pause +goto menu + +:show_config +echo. +echo ======================================== +echo S-UI Configuration +echo ======================================== +if exist "%INSTALL_DIR%\sui.exe" ( + cd /d "%INSTALL_DIR%" + echo Current settings: + sui.exe setting -show + echo. + echo Admin credentials: + sui.exe admin -show +) else ( + echo S-UI executable not found. Please run the installer first. +) +pause +goto menu + +:show_urls +echo. +echo ======================================== +echo Access URLs +echo ======================================== +echo. +echo Local access: +echo Panel: http://localhost:2095 +echo Subscription: http://localhost:2096 +echo. +echo Network access: +for /f "tokens=2 delims=:" %%i in ('ipconfig ^| findstr /i "IPv4"') do ( + set "ip=%%i" + set "ip=!ip: =!" + echo Panel: http://!ip!:2095 + echo Subscription: http://!ip!:2096 +) +echo. +pause +goto menu + +:invalid_choice +echo Invalid choice. Please select a valid option. +pause +goto menu + +:exit +echo Thank you for using S-UI Windows Control Panel! +exit /b 0 diff --git a/windows/s-ui-windows.xml b/windows/s-ui-windows.xml new file mode 100644 index 0000000..79b72bb --- /dev/null +++ b/windows/s-ui-windows.xml @@ -0,0 +1,22 @@ + + + s-ui + S-UI Proxy Panel + S-UI is a proxy panel for managing proxy services + %BASE%\sui.exe + + rotate + %BASE%\logs + + + %BASE% + + + + + + 1 hour + Automatic + tcpip + netman + diff --git a/windows/uninstall-windows.bat b/windows/uninstall-windows.bat new file mode 100644 index 0000000..16df25d --- /dev/null +++ b/windows/uninstall-windows.bat @@ -0,0 +1,102 @@ +@echo off +setlocal enabledelayedexpansion + +echo ======================================== +echo S-UI Windows Uninstaller +echo ======================================== + +REM Check if running as Administrator +net session >nul 2>&1 +if %errorLevel% neq 0 ( + echo Error: This script must be run as Administrator + echo Right-click on this file and select "Run as administrator" + pause + exit /b 1 +) + +REM Set installation directory +set "INSTALL_DIR=C:\Program Files\s-ui" +set "SERVICE_NAME=s-ui" + +echo Uninstalling S-UI from: %INSTALL_DIR% + +REM Stop and remove Windows Service +if exist "%INSTALL_DIR%\s-ui-service.exe" ( + echo Stopping and removing Windows Service... + net stop %SERVICE_NAME% >nul 2>&1 + cd /d "%INSTALL_DIR%" + s-ui-service.exe uninstall >nul 2>&1 + if %errorLevel% equ 0 ( + echo Service removed successfully + ) else ( + echo Warning: Failed to remove service or service was not installed + ) +) + +REM Remove desktop shortcut +echo Removing desktop shortcut... +set "DESKTOP=%USERPROFILE%\Desktop" +if exist "%DESKTOP%\S-UI.lnk" ( + del "%DESKTOP%\S-UI.lnk" >nul 2>&1 + echo Desktop shortcut removed +) + +REM Remove Start Menu shortcut +echo Removing Start Menu shortcut... +set "START_MENU=%APPDATA%\Microsoft\Windows\Start Menu\Programs\S-UI" +if exist "%START_MENU%" ( + rmdir /s /q "%START_MENU%" >nul 2>&1 + echo Start Menu shortcut removed +) + +REM Remove environment variable +echo Removing environment variable... +reg delete "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v SUI_HOME /f >nul 2>&1 + +REM Ask user if they want to keep data +echo. +set /p keep_data="Do you want to keep your data (database, logs, certificates)? [y/n]: " +if /i "%keep_data%"=="y" ( + echo Keeping data files... + REM Remove only executable and service files + if exist "%INSTALL_DIR%\sui.exe" del "%INSTALL_DIR%\sui.exe" >nul 2>&1 + if exist "%INSTALL_DIR%\s-ui-service.exe" del "%INSTALL_DIR%\s-ui-service.exe" >nul 2>&1 + if exist "%INSTALL_DIR%\s-ui-service.xml" del "%INSTALL_DIR%\s-ui-service.xml" >nul 2>&1 + if exist "%INSTALL_DIR%\winsw.exe" del "%INSTALL_DIR%\winsw.exe" >nul 2>&1 + if exist "%INSTALL_DIR%\*.bat" del "%INSTALL_DIR%\*.bat" >nul 2>&1 + if exist "%INSTALL_DIR%\*.xml" del "%INSTALL_DIR%\*.xml" >nul 2>&1 + if exist "%INSTALL_DIR%\*.md" del "%INSTALL_DIR%\*.md" >nul 2>&1 + echo Data files preserved in: %INSTALL_DIR% +) else ( + echo Removing all files... + REM Remove entire installation directory + if exist "%INSTALL_DIR%" ( + rmdir /s /q "%INSTALL_DIR%" >nul 2>&1 + if exist "%INSTALL_DIR%" ( + echo Warning: Some files could not be removed. Please manually delete: %INSTALL_DIR% + ) else ( + echo All files removed successfully + ) + ) +) + +REM Remove firewall rules +echo Removing firewall rules... +netsh advfirewall firewall delete rule name="S-UI Panel" >nul 2>&1 +netsh advfirewall firewall delete rule name="S-UI Subscription" >nul 2>&1 + +echo. +echo ======================================== +echo Uninstallation completed! +echo ======================================== +echo. +echo S-UI has been uninstalled from your system. +echo. +if /i "%keep_data%"=="y" ( + echo Your data has been preserved in: %INSTALL_DIR% + echo You can safely delete this directory if you no longer need the data. +) +echo. +echo Thank you for using S-UI! +echo. +pause