add
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
github: alireza0
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -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
|
||||
@@ -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: "2fef65f9dba90ddb89a87d00a6eb6165487c10c1"
|
||||
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
|
||||
@@ -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
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
.DS_Store
|
||||
dist/
|
||||
release/
|
||||
backup/
|
||||
bin/
|
||||
db/
|
||||
sui
|
||||
web/html
|
||||
main
|
||||
tmp
|
||||
.sync*
|
||||
*.tar.gz
|
||||
|
||||
# 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
|
||||
+48
@@ -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.25-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" ]
|
||||
@@ -0,0 +1,43 @@
|
||||
FROM golang:1.25-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" ]
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,2 +1,259 @@
|
||||
# s-ui
|
||||
s-ui最后一个版本
|
||||
# S-UI
|
||||
**An Advanced Web Panel • Built on SagerNet/Sing-Box**
|
||||
|
||||

|
||||

|
||||
[](https://goreportcard.com/report/github.com/alireza0/s-ui)
|
||||
[](https://img.shields.io/github/downloads/alireza0/s-ui/total.svg)
|
||||
[](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.
|
||||
|
||||
[](https://www.buymeacoffee.com/alireza7)
|
||||
|
||||
<a href="https://nowpayments.io/donation/alireza7" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://nowpayments.io/images/embeds/donation-button-white.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
[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
|
||||
|
||||
<details>
|
||||
<summary>Click for details</summary>
|
||||
|
||||
### 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 .
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Manual run ( contribution )
|
||||
|
||||
<details>
|
||||
<summary>Click for details</summary>
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 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
|
||||
|
||||
<details>
|
||||
<summary>Click for details</summary>
|
||||
|
||||
### 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` | - |
|
||||
|
||||
</details>
|
||||
|
||||
## SSL Certificate
|
||||
|
||||
<details>
|
||||
<summary>Click for details</summary>
|
||||
|
||||
### 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 <Your Domain Name>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Stargazers over Time
|
||||
[](https://starchart.cc/alireza0/s-ui)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+128
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
+112
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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
|
||||
}
|
||||
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!")
|
||||
}
|
||||
+217
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
s-ui
|
||||
@@ -0,0 +1 @@
|
||||
1.4.1
|
||||
+592
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+242
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
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 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()
|
||||
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
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
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 = "&"
|
||||
}
|
||||
dsn := dbPath + sep + "_busy_timeout=10000&_journal_mode=WAL"
|
||||
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(5)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
not ie 11
|
||||
@@ -0,0 +1,5 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,2 @@
|
||||
engine-strict=false
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,76 @@
|
||||
# S-UI-Frontend
|
||||
** A frontend for S-UI **
|
||||
|
||||
[](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
|
||||
|
||||
## [Screenshots](./screenshots.md)
|
||||
|
||||
## Project setup
|
||||
|
||||
```
|
||||
# yarn
|
||||
yarn
|
||||
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
|
||||
```
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# bun
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
|
||||
```
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# bun
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
|
||||
```
|
||||
# yarn
|
||||
yarn lint
|
||||
|
||||
# npm
|
||||
npm run lint
|
||||
|
||||
# pnpm
|
||||
pnpm lint
|
||||
|
||||
# bun
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
|
||||
See [Configuration Reference](https://vitejs.dev/config/).
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="assets/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script>
|
||||
window.BASE_URL = "{{ .BASE_URL }}"
|
||||
|
||||
// Dev Mode
|
||||
if (window.BASE_URL.charAt(0) === '{') window.BASE_URL = "/app/"
|
||||
</script>
|
||||
<title>S-UI</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+3824
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "s-ui-frontend",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.4.47",
|
||||
"axios": "^1.14.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"clipboard": "^2.0.11",
|
||||
"core-js": "^3.49.0",
|
||||
"moment": "^2.30.1",
|
||||
"notivue": "^2.4.5",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode.vue": "^3.8.0",
|
||||
"roboto-fontface": "^0.10.0",
|
||||
"vue": "^3.5.31",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^5.0.4",
|
||||
"vue3-persian-datetime-picker": "^1.2.2",
|
||||
"vuetify": "^4.0.4",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"material-design-icons-iconfont": "^6.7.0",
|
||||
"sass": "^1.98.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.3",
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<v-overlay
|
||||
:model-value="loading"
|
||||
persistent
|
||||
content-class="text-center"
|
||||
class="align-center justify-center"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="64"
|
||||
></v-progress-circular>
|
||||
<br />
|
||||
{{ $t('loading') }}
|
||||
</v-overlay>
|
||||
<Message />
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Message from '@/components/message.vue'
|
||||
import { inject, ref, Ref } from 'vue'
|
||||
|
||||
const loading:Ref = inject('loading')?? ref(false)
|
||||
|
||||
// Change page title
|
||||
document.title = "S-UI " + document.location.hostname
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-overlay .v-list-item,
|
||||
.v-field__input {
|
||||
direction: ltr;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 763 B |
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="1.019 0.0225 45.9789 46.9775" width="45.9789" height="46.9775" xmlns="http://www.w3.org/2000/svg">
|
||||
<g featurekey="symbolFeature-0" transform="matrix(0.4545450210571289, 0, 0, 0.4545450210571289, 0.7917079329490662, 1.7009549140930176)" fill="#737373">
|
||||
<g xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path d="M50,99.658L0.5,70.699V29.301L50,0.341l49.5,28.959v41.398L50,99.658z M2.5,69.553L50,97.342l47.5-27.789V30.448L50,2.659 L2.5,30.448V69.553z"/>
|
||||
</g>
|
||||
<g>
|
||||
<polygon points="51,98.376 49,98.376 49,58.822 0.995,30.738 2.005,29.011 50,57.091 97.995,29.011 99.005,30.738 51,58.822 "/>
|
||||
</g>
|
||||
<g>
|
||||
<polyline points="28.494,14.082 76.994,42.457 71.506,45.667 23.006,17.292 "/>
|
||||
<polygon points="71.507,46.246 71.254,46.098 22.754,17.724 23.259,16.861 71.507,45.087 76.003,42.457 28.241,14.514 28.746,13.65 77.983,42.457 "/>
|
||||
</g>
|
||||
<g>
|
||||
<polyline points="71.506,45.667 71.506,57.982 71.51,57.982 76.993,54.775 76.993,42.457 "/>
|
||||
<polyline points="71.006,45.667 72.006,45.667 72.006,57.113 76.493,54.487 76.493,42.457 77.493,42.457 77.493,55.062 71.646,58.482 71,58.85 "/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g featurekey="nameFeature-0" transform="matrix(1.6160469055175781, 0, 0, 1.6160469055175781, 3.2854819297790527, -21.369783401489258)" fill="#a6a6a6">
|
||||
<path d="M10.904 40.4028 c-5.316 0 -9.2256 -3.048 -9.2256 -6.6192 c0 -1.8592 1.2372 -2.8152 2.5116 -2.8152 c1.0924 0 2.2288 0.7184 2.2288 2.1488 c0 1.4428 -1.4112 1.9972 -1.4112 2.9972 c0 1.782 2.974 3.0552 5.338 3.0552 c3.244 0 6.382 -1.5736 6.382 -5.102 c0 -6.2716 -14.403 -3.6536 -14.403 -13.229 c0 -5.0924 4.3436 -7.6012 9.9036 -7.6012 c4.7364 0 8.9656 2.6496 8.9656 6.1048 c0 1.946 -1.2372 2.9308 -2.4828 2.9308 c-1.1216 0 -2.258 -0.7476 -2.258 -2.178 c0 -1.5584 1.382 -1.8812 1.382 -2.8812 c0 -1.6952 -2.974 -2.7436 -5.3092 -2.7436 c-3.04 0 -5.9296 1.1848 -5.9296 4.6496 c0 5.866 15.193 3.7708 15.193 13.345 c0 4.0208 -3.8304 7.938 -10.886 7.938 z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('out.addr')"
|
||||
hide-details
|
||||
required
|
||||
v-model="addr.server">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('out.port')"
|
||||
hide-details
|
||||
type="number"
|
||||
required
|
||||
v-model.number="addr.server_port"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionRemark">
|
||||
<v-text-field
|
||||
:label="$t('in.remark')"
|
||||
hide-details
|
||||
v-model="addr.remark">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<OutTLS :outbound="addr" v-if="optionTLS" />
|
||||
<v-row>
|
||||
<v-spacer></v-spacer>
|
||||
<v-col cols="auto" align="end" justify="center">
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('in.mdOption') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionRemark" color="primary" :label="$t('in.remark')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="hasTls">
|
||||
<v-switch v-model="optionTLS" color="primary" :label="$t('objects.tls')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import OutTLS from '@/components/tls/OutTLS.vue'
|
||||
export default {
|
||||
props: ['addr', 'hasTls'],
|
||||
data() {
|
||||
return {
|
||||
menu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
optionTLS: {
|
||||
get(): boolean { return this.$props.addr.tls != undefined },
|
||||
set(v:boolean) { this.$props.addr.tls = v ? { enabled: true } : undefined }
|
||||
},
|
||||
optionRemark: {
|
||||
get(): boolean { return this.$props.addr.remark != undefined },
|
||||
set(v:boolean) { this.$props.addr.remark = v ? '' : undefined }
|
||||
}
|
||||
},
|
||||
components: {
|
||||
OutTLS
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<v-text-field
|
||||
id="expiry"
|
||||
:label="$t('date.expiry')"
|
||||
v-model="dateFormatted"
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
readonly
|
||||
hide-details
|
||||
></v-text-field>
|
||||
<DatePicker
|
||||
v-model="Input"
|
||||
@input="Input=$event"
|
||||
:locale="locale"
|
||||
element="expiry"
|
||||
compact-time
|
||||
type="datetime">
|
||||
<template v-slot:next-month>
|
||||
<v-icon icon="mdi-chevron-right" />
|
||||
</template>
|
||||
<template v-slot:prev-month>
|
||||
<v-icon icon="mdi-chevron-left" />
|
||||
</template>
|
||||
<template #submit-btn="{ submit, canSubmit }">
|
||||
<v-btn
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
>{{ $t('submit') }}</v-btn>
|
||||
</template>
|
||||
<template #cancel-btn="{ vm }">
|
||||
<v-btn
|
||||
@click="reset(vm)"
|
||||
>{{ $t('reset') }}</v-btn>
|
||||
</template>
|
||||
<template #now-btn="{ goToday }">
|
||||
<v-btn
|
||||
@click="goToday"
|
||||
>{{ $t('now') }}</v-btn>
|
||||
</template>
|
||||
</DatePicker>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import DatePicker from 'vue3-persian-datetime-picker'
|
||||
import { i18n, locale } from '@/locales'
|
||||
import 'moment/locale/ru'
|
||||
import 'moment/locale/vi'
|
||||
import 'moment/locale/zh-cn'
|
||||
import 'moment/locale/zh-tw'
|
||||
|
||||
export default {
|
||||
props: ['expiry'],
|
||||
emits: ['submit'],
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
input: new Date(),
|
||||
}
|
||||
},
|
||||
components: { DatePicker },
|
||||
computed: {
|
||||
locale() {
|
||||
return locale
|
||||
},
|
||||
dateFormatted() {
|
||||
if (this.expDate == 0) return i18n.global.t('unlimited')
|
||||
const date = new Date(this.expDate*1000)
|
||||
return date.toLocaleString(locale)
|
||||
},
|
||||
expDate() {
|
||||
return parseInt(this.expiry?? 0)
|
||||
},
|
||||
Input: {
|
||||
get() { return this.expDate == 0 ? new Date() : new Date(this.expDate*1000) },
|
||||
set(v:string) {
|
||||
this.input = new Date(v)
|
||||
this.submit()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateInput(v:Date) {
|
||||
this.input = v
|
||||
},
|
||||
setNow() {
|
||||
this.input = new Date()
|
||||
},
|
||||
submit() {
|
||||
this.$emit('submit',Math.floor(this.input.getTime()/1000))
|
||||
},
|
||||
reset(vm:any) {
|
||||
this.$emit('submit',0)
|
||||
this.input = new Date()
|
||||
vm.visible = false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
menu(v) {
|
||||
if (v) {
|
||||
this.input = this.expiry == 0 ? new Date() : new Date(this.expDate*1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.vpd-addon-list,
|
||||
.vpd-addon-list-item {
|
||||
background-color: rgb(var(--v-theme-background)) !important;
|
||||
border-color: rgb(var(--v-theme-background)) !important;
|
||||
}
|
||||
.vpd-content {
|
||||
background-color: rgb(var(--v-theme-background)) !important;
|
||||
}
|
||||
.vpd-addon-list-item.vpd-selected,
|
||||
.vpd-addon-list-item:hover {
|
||||
background-color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
.vpd-close-addon {
|
||||
color: rgb(var(--v-theme-on-surface)) !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
.vpd-controls {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.vpd-month-label {
|
||||
width: auto;
|
||||
}
|
||||
.vpd-actions button:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
.vpd-wrapper[data-type=datetime].vpd-compact-time .vpd-time {
|
||||
border-top: 0;
|
||||
}
|
||||
.vpd-time .vpd-time-h .vpd-counter-item,
|
||||
.vpd-time .vpd-time-m .vpd-counter-item {
|
||||
vertical-align: top;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<v-card :subtitle="$t('objects.dial')" style="background-color: inherit;">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionDetour">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('dial.detourText')"
|
||||
:items="outTags"
|
||||
v-model="dial.detour">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionBind">
|
||||
<v-text-field
|
||||
:label="$t('dial.bindIf')"
|
||||
hide-details
|
||||
v-model="dial.bind_interface"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionIPV4">
|
||||
<v-text-field
|
||||
:label="$t('dial.bindIp4')"
|
||||
hide-details
|
||||
v-model="dial.inet4_bind_address"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionIPV6">
|
||||
<v-text-field
|
||||
:label="$t('dial.bindIp6')"
|
||||
hide-details
|
||||
v-model="dial.inet6_bind_address"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionBindNoPort">
|
||||
<v-switch v-model="dial.bind_address_no_port" color="primary" :label="$t('dial.bindNoPort')" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionRM">
|
||||
<v-text-field
|
||||
label="Linux Routing Mark"
|
||||
hide-details
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="routingMark"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionRA">
|
||||
<v-switch v-model="dial.reuse_addr" color="primary" :label="$t('dial.reuseAddr')" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionTCP">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="dial.tcp_fast_open" color="primary" label="TCP Fast Open" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="dial.tcp_multi_path" color="primary" label="TCP Multi Path" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionTcpKeepAlive">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="dial.disable_tcp_keep_alive" color="primary" :label="$t('dial.disableTcpKeepAlive')" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field v-model="dial.tcp_keep_alive" :label="$t('dial.tcpKeepAlive')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field v-model="dial.tcp_keep_alive_interval" :label="$t('dial.tcpKeepAliveInterval')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionUDP">
|
||||
<v-switch v-model="dial.udp_fragment" color="primary" label="UDP Fragment" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionCT">
|
||||
<v-text-field
|
||||
:label="$t('dial.connTimeout')"
|
||||
hide-details
|
||||
type="number"
|
||||
min="1"
|
||||
:suffix="$t('date.s')"
|
||||
v-model.number="connectTimeout"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionDR">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('dial.domainResolver')"
|
||||
:items="dnsTags"
|
||||
v-model="dial.domain_resolver">
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card-actions class="pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('dial.options') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item v-if="mode != 'client'">
|
||||
<v-switch v-model="optionDetour" color="primary" :label="$t('listen.detour')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mode != 'client'">
|
||||
<v-switch v-model="optionBind" color="primary" :label="$t('dial.bindIf')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mode != 'client'">
|
||||
<v-switch v-model="optionIPV4" color="primary" :label="$t('dial.bindIp4')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mode != 'client'">
|
||||
<v-switch v-model="optionIPV6" color="primary" :label="$t('dial.bindIp6')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mode != 'client'">
|
||||
<v-switch v-model="optionBindNoPort" color="primary" :label="$t('dial.bindNoPort')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mode != 'client'">
|
||||
<v-switch v-model="optionRM" color="primary" label="Routing Mark" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mode != 'client'">
|
||||
<v-switch v-model="optionRA" color="primary" :label="$t('dial.reuseAddr')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionTCP" color="primary" :label="$t('listen.tcpOptions')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionUDP" color="primary" :label="$t('listen.udpOptions')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionCT" color="primary" :label="$t('dial.connTimeout')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionTcpKeepAlive" color="primary" :label="$t('dial.tcpKeepAlive')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mode != 'client'">
|
||||
<v-switch v-model="optionDR" color="primary" :label="$t('dial.domainResolver')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Data from '@/store/modules/data'
|
||||
|
||||
export default {
|
||||
props: ['dial', 'mode'],
|
||||
data() {
|
||||
return {
|
||||
menu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
outTags() { return [...Data().outbounds?.map((o:any) => o.tag), ...Data().endpoints?.map((e:any) => e.tag)] },
|
||||
connectTimeout: {
|
||||
get() { return this.$props.dial.connect_timeout ? parseInt(this.$props.dial.connect_timeout.replace('s','')) : 5 },
|
||||
set(newValue:number) { this.$props.dial.connect_timeout = newValue > 0 ? newValue + 's' : '5s' }
|
||||
},
|
||||
routingMark: {
|
||||
get() { return this.$props.dial.routing_mark?? 0 },
|
||||
set(newValue:number) { this.$props.dial.routing_mark = newValue > 0 ? newValue : 0 }
|
||||
},
|
||||
optionDetour: {
|
||||
get(): boolean { return this.$props.dial.detour != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.detour = this.outTags[0]?? '' : delete this.$props.dial.detour }
|
||||
},
|
||||
optionBind: {
|
||||
get(): boolean { return this.$props.dial.bind_interface != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.bind_interface = '' : delete this.$props.dial.bind_interface }
|
||||
},
|
||||
optionIPV4: {
|
||||
get(): boolean { return this.$props.dial.inet4_bind_address != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.inet4_bind_address = '' : delete this.$props.dial.inet4_bind_address }
|
||||
},
|
||||
optionIPV6: {
|
||||
get(): boolean { return this.$props.dial.inet6_bind_address != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.inet6_bind_address = '' : delete this.$props.dial.inet6_bind_address }
|
||||
},
|
||||
optionBindNoPort: {
|
||||
get(): boolean { return this.$props.dial.bind_address_no_port != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.bind_address_no_port = true : delete this.$props.dial.bind_address_no_port }
|
||||
},
|
||||
optionTcpKeepAlive: {
|
||||
get(): boolean {
|
||||
return this.$props.dial.disable_tcp_keep_alive != undefined ||
|
||||
this.$props.dial.tcp_keep_alive != undefined ||
|
||||
this.$props.dial.tcp_keep_alive_interval != undefined
|
||||
},
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.dial.tcp_keep_alive = '5m'
|
||||
this.$props.dial.tcp_keep_alive_interval = '75s'
|
||||
} else {
|
||||
delete this.$props.dial.disable_tcp_keep_alive
|
||||
delete this.$props.dial.tcp_keep_alive
|
||||
delete this.$props.dial.tcp_keep_alive_interval
|
||||
}
|
||||
}
|
||||
},
|
||||
optionRM: {
|
||||
get(): boolean { return this.$props.dial.routing_mark != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.routing_mark = 0 : delete this.$props.dial.routing_mark }
|
||||
},
|
||||
optionRA: {
|
||||
get(): boolean { return this.$props.dial.reuse_addr != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.reuse_addr = true : delete this.$props.dial.reuse_addr }
|
||||
},
|
||||
optionTCP: {
|
||||
get(): boolean {
|
||||
return this.$props.dial.tcp_fast_open != undefined &&
|
||||
this.$props.dial.tcp_multi_path != undefined
|
||||
},
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.dial.tcp_fast_open = false
|
||||
this.$props.dial.tcp_multi_path = false
|
||||
} else {
|
||||
delete this.$props.dial.tcp_fast_open
|
||||
delete this.$props.dial.tcp_multi_path
|
||||
}
|
||||
}
|
||||
},
|
||||
optionUDP: {
|
||||
get(): boolean { return this.$props.dial.udp_fragment != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.udp_fragment = true : delete this.$props.dial.udp_fragment }
|
||||
},
|
||||
optionCT: {
|
||||
get(): boolean { return this.$props.dial.connect_timeout != undefined },
|
||||
set(v:boolean) { v ? this.$props.dial.connect_timeout = '5s' : delete this.$props.dial.connect_timeout }
|
||||
},
|
||||
optionDR: {
|
||||
get(): boolean { return this.$props.dial.domain_resolver != undefined },
|
||||
set(v:boolean) { this.$props.dial.domain_resolver = v ? this.dnsTags[0]?? '' : undefined }
|
||||
},
|
||||
dnsTags() {return Data().config.dns?.servers?.map((d:any) => d.tag) ?? []}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<v-card style="background-color: inherit;">
|
||||
<v-row>
|
||||
<v-col cols="12" v-if="optionInbound">
|
||||
<v-combobox
|
||||
v-model="rule.inbound"
|
||||
:items="inTags"
|
||||
:label="$t('pages.inbounds')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" v-if="optionClient">
|
||||
<v-combobox
|
||||
v-model="rule.auth_user"
|
||||
:items="clients"
|
||||
:label="$t('pages.clients')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionIPver">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('rule.ipVer')"
|
||||
:items="[4,6]"
|
||||
v-model.number="rule.ip_version">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="optionProtocol">
|
||||
<v-combobox
|
||||
v-model="rule.protocol"
|
||||
:items="['http','tls', 'quic', 'stun', 'dns']"
|
||||
:label="$t('protocol')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionDomain">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="domainKeys"
|
||||
@update:model-value="updateDomainOption($event)"
|
||||
v-model="domainOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.domain != undefined">
|
||||
<v-text-field
|
||||
:label="$t('rule.domain') + ' ' + $t('commaSeparated')"
|
||||
hide-details
|
||||
v-model="domain"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.domain_suffix != undefined">
|
||||
<v-text-field
|
||||
:label="$t('rule.domainSufix') + ' ' + $t('commaSeparated')"
|
||||
hide-details
|
||||
v-model="domain_suffix"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.domain_keyword != undefined">
|
||||
<v-text-field
|
||||
:label="$t('rule.domainKw') + ' ' + $t('commaSeparated')"
|
||||
hide-details
|
||||
v-model="domain_keyword"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.domain_regex != undefined">
|
||||
<v-text-field
|
||||
:label="$t('rule.domainRgx') + ' ' + $t('commaSeparated')"
|
||||
hide-details
|
||||
v-model="domain_regex"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionPort">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="portKeys"
|
||||
@update:model-value="updatePortOption($event)"
|
||||
v-model="portOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.port != undefined">
|
||||
<v-text-field
|
||||
:label="$t('rule.port') + ' ' + $t('commaSeparated')"
|
||||
hide-details
|
||||
v-model="port"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.port_range != undefined">
|
||||
<v-text-field
|
||||
:label="$t('rule.portRange') + ' ' + $t('commaSeparated')"
|
||||
hide-details
|
||||
v-model="port_range"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionSrcIP">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="srcIPKeys"
|
||||
@update:model-value="updateSrcIPOption($event)"
|
||||
v-model="srcIPOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionSrcPort">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="srcPortKeys"
|
||||
@update:model-value="updateSrcPortOption($event)"
|
||||
v-model="srcPortOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.source_port != undefined">
|
||||
<v-text-field
|
||||
:label="$t('rule.srcPort') + ' ' + $t('commaSeparated')"
|
||||
hide-details
|
||||
v-model="source_port"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.source_port_range != undefined">
|
||||
<v-text-field
|
||||
:label="$t('rule.srcPortRange') + ' ' + $t('commaSeparated')"
|
||||
hide-details
|
||||
v-model="source_port_range"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionRuleSet">
|
||||
<v-col cols="12" sm="6">
|
||||
<v-combobox
|
||||
v-model="rule.rule_set"
|
||||
:items="ruleSets"
|
||||
:label="$t('rule.ruleset')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('rule.options') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionInbound" color="primary" :label="$t('pages.inbounds')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionClient" color="primary" :label="$t('pages.clients')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionIPver" color="primary" :label="$t('rule.ipVer')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionProtocol" color="primary" :label="$t('protocol')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionDomain" color="primary" :label="$t('rule.domainRules')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionPort" color="primary" :label="$t('in.port')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionSrcIP" color="primary" :label="$t('rule.srcIpRules')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionSrcPort" color="primary" :label="$t('rule.srcPortRules')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionRuleSet" color="primary" :label="$t('rule.ruleset')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: ['rule', 'clients', 'inTags', 'rsTags', 'deleteable', 'ruleSets'],
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
domainKeys: ['domain', 'domain_suffix', 'domain_keyword', 'domain_regex'],
|
||||
portKeys: ['port', 'port_range'],
|
||||
srcIPKeys: ['source_ip_cidr', 'source_ip_is_private'],
|
||||
srcPortKeys: ['source_port', 'source_port_range'],
|
||||
domainOption: 'domain',
|
||||
portOption: 'port',
|
||||
srcIPOption: 'source_ip_cidr',
|
||||
srcPortOption: 'source_port',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateDomainOption(option:string) {
|
||||
this.domainKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = []
|
||||
},
|
||||
updatePortOption(option:string) {
|
||||
this.portKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = []
|
||||
},
|
||||
updateSrcIPOption(option:string) {
|
||||
this.srcIPKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = option == 'source_ip_is_private' ? false : []
|
||||
},
|
||||
updateSrcPortOption(option:string) {
|
||||
this.srcPortKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = []
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
optionInbound: {
|
||||
get() { return this.$props.rule.inbound != undefined },
|
||||
set(v:boolean) { this.$props.rule.inbound = v ? [] : undefined }
|
||||
},
|
||||
optionClient: {
|
||||
get() { return this.$props.rule.auth_user != undefined },
|
||||
set(v:boolean) { this.$props.rule.auth_user = v ? [] : undefined }
|
||||
},
|
||||
optionIPver: {
|
||||
get() { return this.$props.rule.ip_version != undefined },
|
||||
set(v:boolean) { this.$props.rule.ip_version = v ? 4 : undefined }
|
||||
},
|
||||
optionProtocol: {
|
||||
get() { return this.$props.rule.protocol != undefined },
|
||||
set(v:boolean) { this.$props.rule.protocol = v ? ['http'] : undefined }
|
||||
},
|
||||
optionDomain: {
|
||||
get() { return Object.keys(this.$props.rule).some(r => this.domainKeys.includes(r)) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.domain = []
|
||||
} else {
|
||||
this.domainKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.domainOption = 'domain'
|
||||
}
|
||||
},
|
||||
optionPort: {
|
||||
get() { return Object.keys(this.$props.rule).some(r => this.portKeys.includes(r)) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.port = []
|
||||
} else {
|
||||
this.portKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.portOption = 'port'
|
||||
}
|
||||
},
|
||||
optionSrcIP: {
|
||||
get() { return Object.keys(this.$props.rule).some(r => this.srcIPKeys.includes(r)) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.source_ip_cidr = []
|
||||
} else {
|
||||
this.srcIPKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.srcIPOption = 'source_ip_cidr'
|
||||
}
|
||||
},
|
||||
optionSrcPort: {
|
||||
get() { return Object.keys(this.$props.rule).some(r => this.srcPortKeys.includes(r)) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.source_port = []
|
||||
} else {
|
||||
this.srcPortKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.srcPortOption = 'source_port'
|
||||
}
|
||||
},
|
||||
optionRuleSet: {
|
||||
get() { return this.$props.rule.rule_set != undefined },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.rule_set = []
|
||||
} else {
|
||||
delete this.$props.rule.rule_set
|
||||
}
|
||||
}
|
||||
},
|
||||
domain: {
|
||||
get() { return this.$props.rule.domain?.join(',') },
|
||||
set(v:string) { this.$props.rule.domain = v.length>0 ? v.split(',') : [] }
|
||||
},
|
||||
domain_suffix: {
|
||||
get() { return this.$props.rule.domain_suffix?.join(',') },
|
||||
set(v:string) { this.$props.rule.domain_suffix = v.length>0 ? v.split(',') : [] }
|
||||
},
|
||||
domain_keyword: {
|
||||
get() { return this.$props.rule.domain_keyword?.join(',') },
|
||||
set(v:string) { this.$props.rule.domain_keyword = v.length>0 ? v.split(',') : [] }
|
||||
},
|
||||
domain_regex: {
|
||||
get() { return this.$props.rule.domain_regex?.join(',') },
|
||||
set(v:string) { this.$props.rule.domain_regex = v.length>0 ? v.split(',') : [] }
|
||||
},
|
||||
ip_cidr: {
|
||||
get() { return this.$props.rule.ip_cidr?.join(',') },
|
||||
set(v:string) { this.$props.rule.ip_cidr = v.length>0 ? v.split(',') : [] }
|
||||
},
|
||||
port: {
|
||||
get() { return this.$props.rule.port?.join(',') },
|
||||
set(v:string) {
|
||||
if(!v.endsWith(',')) {
|
||||
this.$props.rule.port = v.length > 0 ? v.split(',').map(str => parseInt(str, 10)) : []
|
||||
}
|
||||
}
|
||||
},
|
||||
port_range: {
|
||||
get() { return this.$props.rule.port_range?.join(',') },
|
||||
set(v:string) { this.$props.rule.port_range = v.length>0 ? v.split(',') : [] }
|
||||
},
|
||||
source_ip_cidr: {
|
||||
get() { return this.$props.rule.source_ip_cidr?.join(',') },
|
||||
set(v:string) { this.$props.rule.source_ip_cidr = v.length>0 ? v.split(',') : [] }
|
||||
},
|
||||
source_port: {
|
||||
get() { return this.$props.rule.source_port?.join(',') },
|
||||
set(v:string) {
|
||||
if(!v.endsWith(',')) {
|
||||
this.$props.rule.source_port = v.length > 0 ? v.split(',').map(str => parseInt(str, 10)) : []
|
||||
}
|
||||
}
|
||||
},
|
||||
source_port_range: {
|
||||
get() { return this.$props.rule.source_port_range?.join(',') },
|
||||
set(v:string) { this.$props.rule.source_port_range = v.length>0 ? v.split(',') : [] }
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const ruleKeys = Object.keys(this.$props.rule)
|
||||
if (this.optionDomain) {
|
||||
const enabledOption = this.domainKeys.filter(k => ruleKeys.includes(k))
|
||||
this.domainOption = enabledOption.length>0 ? enabledOption[0] : 'domain'
|
||||
}
|
||||
if (this.optionPort) {
|
||||
const enabledOption = this.portKeys.filter(k => ruleKeys.includes(k))
|
||||
this.portOption = enabledOption.length>0 ? enabledOption[0] : 'port'
|
||||
}
|
||||
if (this.optionSrcIP) {
|
||||
const enabledOption = this.srcIPKeys.filter(k => ruleKeys.includes(k))
|
||||
this.srcIPOption = enabledOption.length>0 ? enabledOption[0] : 'source_ip_cidr'
|
||||
}
|
||||
if (this.optionSrcPort) {
|
||||
const enabledOption = this.srcPortKeys.filter(k => ruleKeys.includes(k))
|
||||
this.srcPortOption = enabledOption.length>0 ? enabledOption[0] : 'source_port'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<v-dialog transition="dialog-bottom-transition" width="800">
|
||||
<v-card class="rounded-lg">
|
||||
<v-card-title>
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
|
||||
<div class="code-editor">
|
||||
<div class="line-numbers">
|
||||
<span v-for="n in lineCount" :key="n">{{ n }}</span>
|
||||
</div>
|
||||
<v-textarea
|
||||
ref="textareaRef"
|
||||
v-model="content"
|
||||
@scroll="syncScroll"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
bg-color="background"
|
||||
:style="{ 'font-family': 'monospace' }"
|
||||
no-resize
|
||||
auto-grow
|
||||
></v-textarea>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ $t('actions.close') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="saveChanges"
|
||||
>
|
||||
{{ $t('actions.save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
export default {
|
||||
props: ['visible', 'data', 'title'],
|
||||
emits: ['close', 'save'],
|
||||
data() {
|
||||
return {
|
||||
content: this.$props.data,
|
||||
theme: useTheme()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lineCount() {
|
||||
return this.content?.split('\n').length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
syncScroll() {
|
||||
const textarea = document.querySelector('textarea')
|
||||
const lineNumbers = textarea?.parentElement?.parentElement?.querySelector('.line-numbers')
|
||||
if (lineNumbers && textarea) {
|
||||
lineNumbers.scrollTop = textarea.scrollTop
|
||||
}
|
||||
},
|
||||
closeModal() {
|
||||
this.$emit('close')
|
||||
},
|
||||
saveChanges() {
|
||||
this.$emit('save', this.content)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(v) {
|
||||
if (v) {
|
||||
this.content = this.$props.data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-editor {
|
||||
direction: ltr !important;
|
||||
display: flex;
|
||||
border: 1px solid v-bind('theme.current.colors["outline"]');
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 14px; /* Consistent font size */
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
width: 40px;
|
||||
background: v-bind('theme.current.colors["surface"]');
|
||||
text-align: right;
|
||||
padding: 12px 8px 12px 4px; /* Match textarea padding */
|
||||
line-height: 1.5; /* Match textarea line height */
|
||||
overflow-y: hidden; /* Prevent independent scrolling */
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.line-numbers span {
|
||||
display: block;
|
||||
line-height: 1.5; /* Match textarea line height */
|
||||
height: 1.5em; /* Ensure consistent height per line */
|
||||
font-family: monospace; /* Match textarea font */
|
||||
}
|
||||
|
||||
/* Override Vuetify textarea styles for alignment */
|
||||
:deep(.v-textarea .v-field__input) {
|
||||
padding: 12px 8px !important; /* Match line-numbers padding */
|
||||
line-height: 1.5 !important; /* Match line-numbers line height */
|
||||
font-family: monospace !important;
|
||||
white-space: pre;
|
||||
mask-image: inherit;
|
||||
font-size: 14px !important; /* Match font size */
|
||||
}
|
||||
|
||||
/* Ensure textarea and line numbers align */
|
||||
:deep(.v-textarea textarea) {
|
||||
margin-top: 0 !important; /* Remove any default margin */
|
||||
padding-top: 0 !important; /* Remove any default padding */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialog" max-width="620">
|
||||
<v-card>
|
||||
<v-card-title>{{ label }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col>{{ $t('rule.etaHint') }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-textarea
|
||||
v-model="localText"
|
||||
:label="label"
|
||||
variant="outlined"
|
||||
rows="16"
|
||||
:counter="$t('count')"
|
||||
persistent-counter
|
||||
:counter-value="(v: string) => v.split('\n').filter((l: string) => l.trim().length > 0).length"
|
||||
spellcheck="false"
|
||||
></v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn @click="resetChanges" color="error" variant="plain">{{ $t('reset') }}</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn @click="closeModal" color="primary" variant="outlined">{{ $t('actions.close') }}</v-btn>
|
||||
<v-btn @click="saveChanges" color="primary" variant="tonal">{{ $t('actions.save') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
export default {
|
||||
props: ['visible', 'label', 'content'],
|
||||
emits: ['update', 'close'],
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
localText: '',
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(v) {
|
||||
if (v) {
|
||||
this.localText = this.content
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
},
|
||||
methods: {
|
||||
saveChanges() {
|
||||
const unique = [
|
||||
...new Set(
|
||||
this.localText
|
||||
.split('\n')
|
||||
.map((l: string) => l.trim())
|
||||
.filter((l: string) => l.length > 0)
|
||||
),
|
||||
]
|
||||
this.$emit('update', unique)
|
||||
this.dialog = false
|
||||
},
|
||||
resetChanges() {
|
||||
this.localText = this.content
|
||||
},
|
||||
closeModal() {
|
||||
this.$emit('close')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-card-subtitle>
|
||||
{{ $t('objects.headers') }}
|
||||
<v-chip color="primary" density="compact" variant="elevated" @click="add_header">
|
||||
<v-icon icon="mdi-plus" />
|
||||
</v-chip>
|
||||
</v-card-subtitle>
|
||||
<v-row v-for="(header, index) in hdrs">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('objects.key')"
|
||||
hide-details
|
||||
@input="update_key(index,$event.target.value)"
|
||||
v-model="header.name">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('objects.value')"
|
||||
hide-details
|
||||
@input="update_value(index,$event.target.value)"
|
||||
v-model="header.value">
|
||||
<template v-slot:append>
|
||||
<v-icon @click="del_header(index)" color="error" icon="mdi-delete" />
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
type Header = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
export default {
|
||||
props: ['data'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
add_header() {
|
||||
this.hdrs = [...this.hdrs, {name: "Host", value: ""}]
|
||||
},
|
||||
del_header(i:number) {
|
||||
let h = this.hdrs
|
||||
h.splice(i,1)
|
||||
this.hdrs = h
|
||||
},
|
||||
update_key(i:number,k:string) {
|
||||
let h = this.hdrs
|
||||
h[i].name = k
|
||||
this.hdrs = h
|
||||
},
|
||||
update_value(i:number,v:string) {
|
||||
let h = this.hdrs
|
||||
h[i].value = v
|
||||
this.hdrs = h
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hdrs: {
|
||||
get() :Header[] {
|
||||
let headers: Header[] = []
|
||||
const h = this.$props.data.headers
|
||||
if (h) {
|
||||
Object.keys(h).forEach(key => {
|
||||
if (Array.isArray(h[key])){
|
||||
h[key].forEach((v:string) => headers.push({ name: key, value: v }))
|
||||
} else {
|
||||
headers.push({ name: key, value: h[key] })
|
||||
}
|
||||
})
|
||||
}
|
||||
return headers
|
||||
},
|
||||
set(v:Header[]) {
|
||||
if (v.length>0) {
|
||||
let headers:any = {}
|
||||
v.forEach((h:Header) => {
|
||||
if (headers[h.name]) {
|
||||
if (Array.isArray(headers[h.name])) {
|
||||
headers[h.name].push(h.value)
|
||||
} else {
|
||||
headers[h.name] = [headers[h.name], h.value]
|
||||
}
|
||||
} else {
|
||||
headers[h.name] = h.value
|
||||
}
|
||||
})
|
||||
this.$props.data.headers = headers
|
||||
} else {
|
||||
this.$props.data.headers = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<v-card :subtitle="$t('objects.listen')">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('in.addr')"
|
||||
hide-details
|
||||
required
|
||||
v-model="data.listen">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('in.port')"
|
||||
hide-details
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
required
|
||||
v-model.number="data.listen_port"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionDetour">
|
||||
<v-select
|
||||
:label="$t('listen.detourText')"
|
||||
hide-details
|
||||
:items="inTags"
|
||||
v-model="data.detour">
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionTCP">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="data.tcp_fast_open" color="primary" label="TCP Fast Open" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="data.tcp_multi_path" color="primary" label="TCP Multi Path" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionUDP">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="data.udp_fragment" color="primary" label="UDP Fragment" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
label="UDP NAT expiration"
|
||||
hide-details
|
||||
type="number"
|
||||
min="1"
|
||||
:suffix="$t('date.m')"
|
||||
v-model.number="udpTimeout"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionTcpKeepAlive">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="data.disable_tcp_keep_alive" color="primary" :label="$t('listen.disableTcpKeepAlive')" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field v-model="data.tcp_keep_alive" :label="$t('listen.tcpKeepAlive')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field v-model="data.tcp_keep_alive_interval" :label="$t('listen.tcpKeepAliveInterval')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card-actions class="pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('listen.options') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionDetour" color="primary" :label="$t('listen.detour')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionTCP" color="primary" :label="$t('listen.tcpOptions')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionUDP" color="primary" :label="$t('listen.udpOptions')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionTcpKeepAlive" color="primary" :label="$t('listen.tcpKeepAlive')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: ['data', 'inTags'],
|
||||
data() {
|
||||
return {
|
||||
menu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
udpTimeout: {
|
||||
get() { return this.$props.data.udp_timeout ? parseInt(this.$props.data.udp_timeout.replace('m','')) : 5 },
|
||||
set(newValue:number) { this.$props.data.udp_timeout = newValue > 0 ? newValue + 'm' : '5m' }
|
||||
},
|
||||
optionTCP: {
|
||||
get(): boolean {
|
||||
return this.$props.data.tcp_fast_open != undefined &&
|
||||
this.$props.data.tcp_multi_path != undefined
|
||||
},
|
||||
set(v:boolean) {
|
||||
this.$props.data.tcp_fast_open = v ? false : undefined
|
||||
this.$props.data.tcp_multi_path = v ? false : undefined
|
||||
}
|
||||
},
|
||||
optionUDP: {
|
||||
get(): boolean {
|
||||
return this.$props.data.udp_fragment != undefined &&
|
||||
this.$props.data.udp_timeout != undefined
|
||||
},
|
||||
set(v:boolean) {
|
||||
this.$props.data.udp_fragment = v ? false : undefined
|
||||
this.$props.data.udp_timeout = v ? '5m' : undefined
|
||||
}
|
||||
},
|
||||
optionDetour: {
|
||||
get(): boolean { return this.$props.data.detour != undefined },
|
||||
set(v:boolean) { this.$props.data.detour = v ? this.inTags[0]?? '' : undefined }
|
||||
},
|
||||
optionTcpKeepAlive: {
|
||||
get(): boolean {
|
||||
return this.$props.data.disable_tcp_keep_alive != undefined ||
|
||||
this.$props.data.tcp_keep_alive != undefined ||
|
||||
this.$props.data.tcp_keep_alive_interval != undefined
|
||||
},
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.data.tcp_keep_alive = '5m'
|
||||
this.$props.data.tcp_keep_alive_interval = '75s'
|
||||
} else {
|
||||
delete this.$props.data.disable_tcp_keep_alive
|
||||
delete this.$props.data.tcp_keep_alive
|
||||
delete this.$props.data.tcp_keep_alive_interval
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<LogVue v-model="logModal.visible" :control="logModal" :visible="logModal.visible" />
|
||||
<Backup v-model="backupModal.visible" :control="backupModal" :visible="backupModal.visible" />
|
||||
<UsageStats v-model:visible="usageStatsModal.visible" />
|
||||
<v-container class="fill-height" :loading="loading">
|
||||
<v-responsive :class="reloadItems.length>0 ? 'fill-height text-center' : 'align-center'" >
|
||||
<v-row class="d-flex align-center justify-center">
|
||||
<v-col cols="auto">
|
||||
<v-img src="@/assets/logo.svg" :width="reloadItems.length>0 ? 100 : 200"></v-img>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="d-flex align-center justify-center">
|
||||
<v-col cols="auto">
|
||||
<v-dialog v-model="menu" :close-on-content-click="false" transition="scale-transition" max-width="800">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal" elevation="3">{{ $t('main.tiles') }} <v-icon icon="mdi-star-plus" /></v-btn>
|
||||
</template>
|
||||
<v-card rounded="xl">
|
||||
<v-card-title>
|
||||
<v-row>
|
||||
<v-col>
|
||||
{{ $t('main.tiles') }}
|
||||
</v-col>
|
||||
<v-spacer></v-spacer>
|
||||
<v-col cols="auto"><v-icon icon="mdi-close" @click="menu = false"></v-icon></v-col>
|
||||
</v-row>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-row v-for="items in menuItems" density="compact">
|
||||
<v-col cols="12">
|
||||
<v-card :subtitle="items.title" variant="flat">
|
||||
<v-card-text>
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" md="6" lg="3" v-for="item in items.value">
|
||||
<v-switch
|
||||
density="compact"
|
||||
v-model="reloadItems"
|
||||
:value="item.value"
|
||||
color="primary"
|
||||
:label="item.title"
|
||||
hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-btn variant="tonal" hide-details
|
||||
style="margin-inline-start: 10px;" elevation="3"
|
||||
@click="backupModal.visible = true">{{ $t('main.backup.title') }}<v-icon icon="mdi-backup-restore" />
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" hide-details
|
||||
style="margin-inline-start: 10px;" elevation="3"
|
||||
@click="logModal.visible = true">{{ $t('basic.log.title') }} <v-icon icon="mdi-list-box-outline" />
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" hide-details
|
||||
style="margin-inline-start: 10px;" elevation="3"
|
||||
@click="usageStatsModal.visible = true">{{ $t('main.stats.title') }} <v-icon icon="mdi-chart-box-outline" />
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="3" v-for="i in reloadItems" :key="i">
|
||||
<v-card class="rounded-lg" variant="outlined" height="210px" elevation="5">
|
||||
<v-card-title>
|
||||
{{ menuItems.flatMap(cat => cat.value).find(m => m.value == i)?.title }}
|
||||
<template v-if="i == 'i-sys'">
|
||||
<v-icon icon="mdi-update" color="primary"
|
||||
@click="reloadSys()" size="small" v-tooltip:top="$t('actions.update')"
|
||||
style="margin-inline-start: 10px;">
|
||||
</v-icon>
|
||||
</template>
|
||||
<template v-if="i == 'h-net'">
|
||||
<v-icon icon="mdi-information" color="primary" size="small"
|
||||
v-tooltip:top="'↓' +
|
||||
HumanReadable.sizeFormat(tilesData.net?.recv) + ' - ' +
|
||||
HumanReadable.sizeFormat(tilesData.net?.sent) + '↑'"
|
||||
style="margin-inline-start: 10px;">
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-card-title>
|
||||
<v-card-text style="padding: 0 16px;" align="center" justify="center">
|
||||
<Gauge :tilesData="tilesData" :type="i" v-if="i.charAt(0) == 'g'" />
|
||||
<History :tilesData="tilesData" :type="i" v-if="i.charAt(0) == 'h'" />
|
||||
<template v-if="i == 'i-sys'">
|
||||
<v-row>
|
||||
<v-col cols="3">{{ $t('main.info.host') }}</v-col>
|
||||
<v-col cols="9" style="text-wrap: nowrap; overflow: hidden">{{ tilesData.sys?.hostName }}</v-col>
|
||||
<v-col cols="3">{{ $t('main.info.cpu') }}</v-col>
|
||||
<v-col cols="9">
|
||||
<v-chip density="compact" variant="flat">
|
||||
<v-tooltip activator="parent" location="top" style="direction: ltr;">
|
||||
{{ tilesData.sys?.cpuType }}
|
||||
</v-tooltip>
|
||||
{{ tilesData.sys?.cpuCount }} {{ $t('main.info.core') }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="3">IP</v-col>
|
||||
<v-col cols="9">
|
||||
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sys?.ipv4?.length>0">
|
||||
<v-tooltip activator="parent" location="top" style="direction: ltr;">
|
||||
<span v-html="tilesData.sys?.ipv4?.join('<br />')"></span>
|
||||
</v-tooltip>
|
||||
IPv4
|
||||
</v-chip>
|
||||
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sys?.ipv6?.length>0">
|
||||
<v-tooltip activator="parent" location="top" style="direction: ltr;">
|
||||
<span v-html="tilesData.sys?.ipv6?.join('<br />')"></span>
|
||||
</v-tooltip>
|
||||
IPv6
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="3">S-UI</v-col>
|
||||
<v-col cols="9">
|
||||
<v-chip density="compact" color="blue">
|
||||
v{{ tilesData.sys?.appVersion }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="3">{{ $t('main.info.uptime') }}</v-col>
|
||||
<v-col cols="9" v-tooltip:top="$t('main.info.startupTime')
|
||||
+ ': ' + new Date((tilesData.sys?.bootTime || 0) * 1000).toLocaleString(locale)">
|
||||
{{ HumanReadable.formatSecond((Date.now()/1000) - tilesData.sys?.bootTime) }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<template v-if="i == 'i-sbd'">
|
||||
<v-row>
|
||||
<v-col cols="4">{{ $t('main.info.running') }}</v-col>
|
||||
<v-col cols="8">
|
||||
<v-chip density="compact" color="success" variant="flat" v-if="tilesData.sbd?.running">{{ $t('yes') }}</v-chip>
|
||||
<v-chip density="compact" color="error" variant="flat" v-else>{{ $t('no') }}</v-chip>
|
||||
<v-chip density="compact" color="transparent" v-if="tilesData.sbd?.running && !loading" style="cursor: pointer;" @click="restartSingbox()">
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ $t('actions.restartSb') }}
|
||||
</v-tooltip>
|
||||
<v-icon icon="mdi-restart" color="warning" />
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="4">{{ $t('main.info.memory') }}</v-col>
|
||||
<v-col cols="8">
|
||||
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sbd?.stats?.Alloc">
|
||||
{{ HumanReadable.sizeFormat(tilesData.sbd?.stats?.Alloc) }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="4">{{ $t('main.info.threads') }}</v-col>
|
||||
<v-col cols="8">
|
||||
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sbd?.stats?.NumGoroutine">
|
||||
{{ tilesData.sbd?.stats?.NumGoroutine }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="4">{{ $t('main.info.uptime') }}</v-col>
|
||||
<v-col cols="8">{{ HumanReadable.formatSecond(tilesData.sbd?.stats?.Uptime) }}</v-col>
|
||||
<v-col cols="4">{{ $t('online') }}</v-col>
|
||||
<v-col cols="8">
|
||||
<template v-if="tilesData.sbd?.running">
|
||||
<v-chip density="compact" color="primary" variant="flat" v-if="Data().onlines.user">
|
||||
<v-tooltip activator="parent" location="top" overflow="auto">
|
||||
<span v-text="$t('pages.clients')" style="font-weight: bold;"></span><br/>
|
||||
<span v-for="user in Data().onlines.user">{{ user }}<br /></span>
|
||||
</v-tooltip>
|
||||
{{ Data().onlines.user?.length }}
|
||||
</v-chip>
|
||||
<v-chip density="compact" color="success" variant="flat" v-if="Data().onlines.inbound">
|
||||
<v-tooltip activator="parent" location="top" :text="$t('pages.inbounds')">
|
||||
<span v-text="$t('pages.inbounds')" style="font-weight: bold;"></span><br/>
|
||||
<span v-for="i in Data().onlines.inbound">{{ i }}<br /></span>
|
||||
</v-tooltip>
|
||||
{{ Data().onlines.inbound?.length }}
|
||||
</v-chip>
|
||||
<v-chip density="compact" color="info" variant="flat" v-if="Data().onlines.outbound">
|
||||
<v-tooltip activator="parent" location="top" :text="$t('pages.outbounds')">
|
||||
<span v-text="$t('pages.outbounds')" style="font-weight: bold;"></span><br/>
|
||||
<span v-for="o in Data().onlines.outbound">{{ o }}<br /></span>
|
||||
</v-tooltip>
|
||||
{{ Data().onlines.outbound?.length }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-responsive>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import HttpUtils from '@/plugins/httputil'
|
||||
import { HumanReadable } from '@/plugins/utils'
|
||||
import Data from '@/store/modules/data'
|
||||
import Gauge from '@/components/tiles/Gauge.vue'
|
||||
import History from '@/components/tiles/History.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { i18n, locale } from '@/locales'
|
||||
import LogVue from '@/layouts/modals/Logs.vue'
|
||||
import Backup from '@/layouts/modals/Backup.vue'
|
||||
import UsageStats from '@/layouts/modals/UsageStats.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const menu = ref(false)
|
||||
const menuItems = [
|
||||
{ title: i18n.global.t('main.gauges'), value: [
|
||||
{ title: i18n.global.t('main.gauge.cpu'), value: "g-cpu" },
|
||||
{ title: i18n.global.t('main.gauge.mem'), value: "g-mem" },
|
||||
{ title: i18n.global.t('main.gauge.dsk'), value: "g-dsk" },
|
||||
{ title: i18n.global.t('main.gauge.swp'), value: "g-swp" },
|
||||
]
|
||||
},
|
||||
{ title: i18n.global.t('main.charts'), value: [
|
||||
{ title: i18n.global.t('main.chart.cpu'), value: "h-cpu" },
|
||||
{ title: i18n.global.t('main.chart.mem'), value: "h-mem" },
|
||||
{ title: i18n.global.t('main.chart.net'), value: "h-net" },
|
||||
{ title: i18n.global.t('main.chart.pnet'), value: "hp-net" },
|
||||
{ title: i18n.global.t('main.chart.dio'), value: "h-dio" },
|
||||
]
|
||||
},
|
||||
{ title: i18n.global.t('main.infos'), value: [
|
||||
{ title: i18n.global.t('main.info.sys'), value: "i-sys" },
|
||||
{ title: i18n.global.t('main.info.sbd'), value: "i-sbd" },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
const tilesData = ref(<any>{})
|
||||
|
||||
const reloadItems = computed({
|
||||
get() { return Data().reloadItems },
|
||||
set(v:string[]) {
|
||||
if (Data().reloadItems.length == 0 && v.length>0) startTimer()
|
||||
if (Data().reloadItems.length > 0 && v.length == 0) stopTimer()
|
||||
Data().reloadItems = v
|
||||
v.length>0 ? localStorage.setItem("reloadItems",v.join(',')) : localStorage.removeItem("reloadItems")
|
||||
}
|
||||
})
|
||||
|
||||
const reloadData = async () => {
|
||||
const request = [...new Set(reloadItems.value.map(r => r.split('-')[1]))]
|
||||
if (tilesData.value?.sys?.appVersion) request.filter(r => r != 'sys')
|
||||
const data = await HttpUtils.get('api/status',{ r: request.join(',')})
|
||||
if (data.success) {
|
||||
tilesData.value = data.obj
|
||||
}
|
||||
}
|
||||
|
||||
const reloadSys = async () => {
|
||||
const data = await HttpUtils.get('api/status',{ r: 'sys'})
|
||||
if (data.success) {
|
||||
tilesData.value.sys = data.obj.sys
|
||||
}
|
||||
}
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const startTimer = () => {
|
||||
intervalId = setInterval(() => {
|
||||
reloadData()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const stopTimer = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
if (Data().reloadItems.length != 0) {
|
||||
await reloadData()
|
||||
startTimer()
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopTimer()
|
||||
})
|
||||
|
||||
const logModal = ref({ visible: false })
|
||||
|
||||
const backupModal = ref({ visible: false })
|
||||
|
||||
const usageStatsModal = ref({ visible: false })
|
||||
|
||||
const restartSingbox = async () => {
|
||||
loading.value = true
|
||||
await HttpUtils.post('api/restartSb',{})
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<v-card :subtitle="$t('objects.multiplex')">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch color="primary" :label="$t('mux.enable')" v-model="muxEnable" hide-details></v-switch>
|
||||
</v-col>
|
||||
<template v-if="muxEnable">
|
||||
<template v-if="direction=='out'">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="[ 'smux', 'yamux', 'h2mux']"
|
||||
:label="$t('protocol')"
|
||||
clearable
|
||||
@click:clear="delete mux?.protocol"
|
||||
v-model="mux.protocol">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('mux.maxConn')"
|
||||
hide-details
|
||||
type="number"
|
||||
min=0
|
||||
v-model.number="max_connections">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('mux.minStr')"
|
||||
hide-details
|
||||
type="number"
|
||||
min=0
|
||||
v-model.number="min_streams">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('mux.maxStr')"
|
||||
hide-details
|
||||
type="number"
|
||||
:min="min_streams"
|
||||
v-model.number="max_streams">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</template>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch color="primary" :label="$t('mux.padding')" v-model="padding" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch color="primary" :label="$t('mux.enableBrutal')" v-model="burtalEnable" hide-details></v-switch>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
<v-row v-if="mux?.brutal?.enabled">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('stats.upload')"
|
||||
hide-details
|
||||
type="number"
|
||||
:suffix="$t('stats.Mbps')"
|
||||
v-model.number="up_mbps">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('stats.download')"
|
||||
hide-details
|
||||
type="number"
|
||||
:suffix="$t('stats.Mbps')"
|
||||
min="0"
|
||||
v-model.number="down_mbps">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { oMultiplex } from '@/types/multiplex'
|
||||
export default {
|
||||
props: ['data', 'direction'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
mux(): oMultiplex {
|
||||
return <oMultiplex> this.$props.data.multiplex ?? null
|
||||
},
|
||||
muxEnable: {
|
||||
get(): boolean { return this.mux ? this.mux.enabled : false },
|
||||
set(newValue:boolean) { this.$props.data.multiplex = newValue ? { enabled: newValue } : undefined }
|
||||
},
|
||||
max_connections: {
|
||||
get(): number { return this.mux?.max_connections ? this.mux.max_connections : 0 },
|
||||
set(newValue:number) { this.mux.max_connections = newValue > 0 ? newValue : undefined }
|
||||
},
|
||||
min_streams: {
|
||||
get(): number { return this.mux?.min_streams ? this.mux.min_streams : 0 },
|
||||
set(newValue:number) { this.mux.min_streams = newValue > 0 ? newValue : undefined }
|
||||
},
|
||||
max_streams: {
|
||||
get(): number { return this.mux?.max_streams ? this.mux.max_streams : 0 },
|
||||
set(newValue:number) { this.mux.max_streams = newValue > 0 ? newValue : undefined }
|
||||
},
|
||||
padding: {
|
||||
get(): boolean { return this.mux?.padding ? this.mux.padding : false },
|
||||
set(newValue:boolean) { this.mux.padding = newValue ? true : undefined }
|
||||
},
|
||||
burtalEnable: {
|
||||
get(): boolean { return this.mux?.brutal ? this.mux.brutal.enabled : false },
|
||||
set(newValue:boolean) { this.mux.brutal = newValue ? { enabled: newValue, up_mbps: 100, down_mbps: 100 } : undefined }
|
||||
},
|
||||
down_mbps: {
|
||||
get() { return this.mux?.brutal && this.mux.brutal.down_mbps ? this.mux.brutal.down_mbps : 0 },
|
||||
set(newValue:any) {
|
||||
if (this.mux.brutal){
|
||||
this.mux.brutal.down_mbps = newValue.length != 0 ? newValue : 0
|
||||
}
|
||||
}
|
||||
},
|
||||
up_mbps: {
|
||||
get() { return this.mux?.brutal && this.mux.brutal.up_mbps ? this.mux.brutal.up_mbps : 0 },
|
||||
set(newValue:any) {
|
||||
if (this.mux.brutal){
|
||||
this.mux.brutal.up_mbps = newValue.length != 0 ? newValue : 0
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('network')"
|
||||
:items="networks"
|
||||
v-model="Network">
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: ['data'],
|
||||
data() {
|
||||
return {
|
||||
networks: [
|
||||
{ title: "TCP/UDP", value: '' },
|
||||
{ title: "TCP", value: 'tcp' },
|
||||
{ title: "UDP", value: 'udp' },
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
Network: {
|
||||
get():string { return this.$props.data.network?? '' },
|
||||
set(v:string) { this.$props.data.network = v != '' ? v : undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<v-card :subtitle="$t('pages.basics')">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="type == inTypes.SOCKS">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="['4','4a','5']"
|
||||
:label="$t('version')"
|
||||
v-model="inData.out_json.version">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="needNetwork">
|
||||
<Network :data="inData.out_json" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="needUot">
|
||||
<UoT :data="inData.out_json" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="type == inTypes.HTTP">
|
||||
<v-text-field
|
||||
:label="$t('transport.path')"
|
||||
hide-details
|
||||
v-model="inData.out_json.path">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="type == inTypes.VMess || type == inTypes.VLESS">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('types.vless.udpEnc')"
|
||||
:items="['none','packetaddr','xudp']"
|
||||
v-model="packet_encoding">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<template v-if="type == inTypes.VMess">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('types.vmess.security')"
|
||||
:items="vmessSecurities"
|
||||
v-model="inData.out_json.security">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="inData.out_json.global_padding" color="primary" :label="$t('types.vmess.globalPadding')" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="inData.out_json.authenticated_length" color="primary" :label="$t('types.vmess.authLen')" hide-details></v-switch>
|
||||
</v-col>
|
||||
</template>
|
||||
<v-col cols="12" sm="6" md="4" v-if="type == inTypes.Hysteria">
|
||||
<v-text-field
|
||||
label="Recv window"
|
||||
hide-details
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="inData.out_json.recv_window">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<template v-if="type == inTypes.TUIC">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
label="UDP Relay Mode"
|
||||
:items="['native', 'quic']"
|
||||
clearable
|
||||
@click:clear="delete inData.out_json.udp_relay_mode"
|
||||
v-model="inData.out_json.udp_relay_mode">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch color="primary" label="UDP Over Stream" v-model="inData.out_json.udp_over_stream" hide-details></v-switch>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
<v-row v-if="[inTypes.Hysteria, inTypes.Hysteria2].includes(type)">
|
||||
<v-col cols="12" sm="8">
|
||||
<v-text-field
|
||||
:label="$t('rule.portRange') + ' ' + $t('commaSeparated')"
|
||||
v-model="server_ports">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('ruleset.interval')"
|
||||
type="number"
|
||||
min="0"
|
||||
:suffix="$t('date.s')"
|
||||
v-model.number="hop_interval">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<Headers :data="inData.out_json" v-if="type == inTypes.HTTP" />
|
||||
<AnyTls v-if="type == inTypes.AnyTls" :data="inData.out_json" direction="out_json" />
|
||||
<Naive v-if="type == inTypes.Naive" :data="inData.out_json" direction="out_json" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { InTypes } from '@/types/inbounds'
|
||||
import Network from './Network.vue'
|
||||
import UoT from './UoT.vue'
|
||||
import Headers from './Headers.vue'
|
||||
import AnyTls from './protocols/AnyTls.vue'
|
||||
import Naive from './protocols/Naive.vue'
|
||||
|
||||
export default {
|
||||
props: ['inData', 'type'],
|
||||
data() {
|
||||
return {
|
||||
inTypes: InTypes,
|
||||
vmessSecurities: [
|
||||
"auto",
|
||||
"none",
|
||||
"zero",
|
||||
"aes-128-gcm",
|
||||
"aes-128-ctr",
|
||||
"chacha20-poly1305",
|
||||
],
|
||||
haveNetwork: [
|
||||
InTypes.SOCKS,
|
||||
InTypes.Shadowsocks,
|
||||
InTypes.VMess,
|
||||
InTypes.Trojan,
|
||||
InTypes.Hysteria,
|
||||
InTypes.VLESS,
|
||||
InTypes.TUIC,
|
||||
InTypes.Hysteria2,
|
||||
],
|
||||
havUoT: [
|
||||
InTypes.SOCKS,
|
||||
InTypes.Shadowsocks,
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
needNetwork():boolean { return this.haveNetwork.includes(this.$props.type) },
|
||||
needUot():boolean { return this.havUoT.includes(this.$props.type) },
|
||||
packet_encoding: {
|
||||
get() { return this.$props.inData.out_json.packet_encoding != undefined ? this.$props.inData.out_json.packet_encoding : 'none' },
|
||||
set(v:string) { this.$props.inData.out_json.packet_encoding = v != "none" ? v : undefined }
|
||||
},
|
||||
server_ports: {
|
||||
get() { return this.$props.inData.out_json.server_ports?.join(',')?? [] },
|
||||
set(v:string) { this.$props.inData.out_json.server_ports = v.length > 0 ? v.split(',') : undefined }
|
||||
},
|
||||
hop_interval: {
|
||||
get() { return this.$props.inData.out_json.hop_interval? parseInt(this.$props.inData.out_json.hop_interval.replace('s','')) : 0 },
|
||||
set(v:number) { this.$props.inData.out_json.hop_interval = v>0 ? v + 's' : undefined }
|
||||
},
|
||||
},
|
||||
components: { Network, UoT, Headers, AnyTls, Naive }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,568 @@
|
||||
<template>
|
||||
<ExpTextarea
|
||||
v-model="expTextarea.visible"
|
||||
:visible="expTextarea.visible"
|
||||
:label="expTextarea.title"
|
||||
:content="expTextarea.content"
|
||||
@update="saveExpTextarea"
|
||||
@close="closeExpTextarea"
|
||||
/>
|
||||
<v-card style="background-color: inherit;">
|
||||
<v-row>
|
||||
<v-col cols="12" v-if="optionInbound">
|
||||
<v-combobox
|
||||
v-model="rule.inbound"
|
||||
:items="inTags"
|
||||
:label="$t('pages.inbounds')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" v-if="optionClient">
|
||||
<v-combobox
|
||||
v-model="rule.auth_user"
|
||||
:items="clients"
|
||||
:label="$t('pages.clients')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionIPver">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('rule.ipVer')"
|
||||
:items="[4,6]"
|
||||
v-model.number="rule.ip_version">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionNetwork">
|
||||
<v-select
|
||||
hide-details
|
||||
multiple
|
||||
chips
|
||||
:label="$t('network')"
|
||||
:items="['tcp','udp','icmp']"
|
||||
v-model="rule.network">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="optionProtocol">
|
||||
<v-select
|
||||
v-model="rule.protocol"
|
||||
:items="protocols"
|
||||
:label="$t('protocol')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionDomain">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="domainKeys"
|
||||
@update:model-value="updateDomainOption($event)"
|
||||
v-model="domainOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.domain != undefined">
|
||||
<v-textarea :label="$t('rule.domain')"
|
||||
hide-details
|
||||
v-model="domain"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.domain'), 'domain')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.domain_suffix != undefined">
|
||||
<v-textarea :label="$t('rule.domainSufix')"
|
||||
hide-details
|
||||
v-model="domain_suffix"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.domainSufix'), 'domain_suffix')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.domain_keyword != undefined">
|
||||
<v-textarea :label="$t('rule.domainKw')"
|
||||
hide-details
|
||||
v-model="domain_keyword"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.domainKw'), 'domain_keyword')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.domain_regex != undefined">
|
||||
<v-textarea :label="$t('rule.domainRgx')"
|
||||
hide-details
|
||||
v-model="domain_regex"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.domainRgx'), 'domain_regex')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.ip_cidr != undefined">
|
||||
<v-textarea :label="$t('rule.ip')"
|
||||
hide-details
|
||||
v-model="ip_cidr"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.ip'), 'ip_cidr')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.ip_is_private != undefined">
|
||||
<v-switch v-model="rule.ip_is_private" color="primary" :label="$t('rule.privateIp')" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionPort">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="portKeys"
|
||||
@update:model-value="updatePortOption($event)"
|
||||
v-model="portOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.port != undefined">
|
||||
<v-textarea :label="$t('rule.port')"
|
||||
hide-details
|
||||
v-model="port"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.port'), 'port')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.port_range != undefined">
|
||||
<v-textarea :label="$t('rule.portRange')"
|
||||
hide-details
|
||||
v-model="port_range"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.portRange'), 'port_range')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionSrcIP">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="srcIPKeys"
|
||||
@update:model-value="updateSrcIPOption($event)"
|
||||
v-model="srcIPOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.source_ip_cidr != undefined">
|
||||
<v-textarea :label="$t('rule.srcCidr')"
|
||||
hide-details
|
||||
v-model="source_ip_cidr"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.srcCidr'), 'source_ip_cidr')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.source_ip_is_private != undefined">
|
||||
<v-switch v-model="rule.source_ip_is_private" color="primary" :label="$t('rule.srcPrivateIp')" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionSrcPort">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="srcPortKeys"
|
||||
@update:model-value="updateSrcPortOption($event)"
|
||||
v-model="srcPortOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.source_port != undefined">
|
||||
<v-textarea :label="$t('rule.srcPort')"
|
||||
hide-details
|
||||
v-model="source_port"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.srcPort'), 'source_port')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.source_port_range != undefined">
|
||||
<v-textarea :label="$t('rule.srcPortRange')"
|
||||
hide-details
|
||||
v-model="source_port_range"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.srcPortRange'), 'source_port_range')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionPreferredBy">
|
||||
<v-col cols="12" sm="6">
|
||||
<v-combobox
|
||||
v-model="rule.preferred_by"
|
||||
:items="outTags || inTags"
|
||||
:label="$t('rule.preferredBy')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionInterface">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="interfaceKeys"
|
||||
@update:model-value="updateInterfaceOption($event)"
|
||||
v-model="interfaceOption">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" v-if="rule.interface_address != undefined || rule.network_interface_address != undefined || rule.default_interface_address != undefined">
|
||||
<v-textarea :label="$t('rule.interfaceAddr')"
|
||||
hide-details
|
||||
v-model="interface_addr"
|
||||
rows="5"
|
||||
no-resize
|
||||
density="compact"
|
||||
append-icon="mdi-arrow-expand"
|
||||
@click:append="openExpTextarea($t('rule.interfaceAddr'), 'interface_address')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionRuleSet">
|
||||
<v-col cols="12" sm="6">
|
||||
<v-combobox
|
||||
v-model="rule.rule_set"
|
||||
:items="rsTags"
|
||||
:label="$t('rule.ruleset')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-switch v-model="rule.rule_set_ip_cidr_match_source" color="primary" :label="$t('rule.rulesetMatchSrc')" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('rule.options') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionInbound" color="primary" :label="$t('pages.inbounds')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionClient" color="primary" :label="$t('pages.clients')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionIPver" color="primary" :label="$t('rule.ipVer')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionNetwork" color="primary" :label="$t('network')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionProtocol" color="primary" :label="$t('protocol')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionDomain" color="primary" :label="$t('rule.domainRules')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionPort" color="primary" :label="$t('in.port')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionSrcIP" color="primary" :label="$t('rule.srcIpRules')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionSrcPort" color="primary" :label="$t('rule.srcPortRules')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionPreferredBy" color="primary" :label="$t('rule.preferredBy')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionInterface" color="primary" :label="$t('rule.interfaceAddr')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionRuleSet" color="primary" :label="$t('rule.ruleset')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ExpTextarea from '@/components/ExpTextarea.vue'
|
||||
export default {
|
||||
components: { ExpTextarea },
|
||||
props: ['rule', 'clients', 'inTags', 'outTags', 'rsTags', 'deleteable'],
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
domainKeys: ['domain', 'domain_suffix', 'domain_keyword', 'domain_regex', 'ip_cidr', 'ip_is_private'],
|
||||
interfaceKeys: ['interface_address', 'network_interface_address', 'default_interface_address'],
|
||||
portKeys: ['port', 'port_range'],
|
||||
srcIPKeys: ['source_ip_cidr', 'source_ip_is_private'],
|
||||
srcPortKeys: ['source_port', 'source_port_range'],
|
||||
domainOption: 'domain',
|
||||
interfaceOption: 'interface_address',
|
||||
portOption: 'port',
|
||||
srcIPOption: 'source_ip_cidr',
|
||||
srcPortOption: 'source_port',
|
||||
protocols: [
|
||||
{ title: 'HTTP', value: 'http' },
|
||||
{ title: 'TLS', value: 'tls' },
|
||||
{ title: 'QUIC', value: 'quic' },
|
||||
{ title: 'STUN', value: 'stun' },
|
||||
{ title: 'DNS', value: 'dns' },
|
||||
{ title: 'BitTorrent', value: 'bittorrent' },
|
||||
{ title: 'DTLS', value: 'dtls' },
|
||||
{ title: 'SSH', value: 'ssh' },
|
||||
{ title: 'RDP', value: 'rdp' },
|
||||
{ title: 'NTP', value: 'ntp' },
|
||||
],
|
||||
expTextarea: {
|
||||
visible: false,
|
||||
title: '',
|
||||
content: '',
|
||||
object: '',
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateDomainOption(option:string) {
|
||||
this.domainKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = option == 'ip_is_private' ? false : []
|
||||
},
|
||||
updatePortOption(option:string) {
|
||||
this.portKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = []
|
||||
},
|
||||
updateSrcIPOption(option:string) {
|
||||
this.srcIPKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = option == 'source_ip_is_private' ? false : []
|
||||
},
|
||||
updateSrcPortOption(option:string) {
|
||||
this.srcPortKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = []
|
||||
},
|
||||
updateInterfaceOption(option:string) {
|
||||
this.interfaceKeys.forEach(k => delete this.$props.rule[k])
|
||||
this.$props.rule[option] = []
|
||||
},
|
||||
openExpTextarea(title:string, object:string) {
|
||||
this.expTextarea.visible = !this.expTextarea.visible
|
||||
this.expTextarea.title = title
|
||||
this.expTextarea.content = this.$props.rule[object]?.join('\n') ?? ''
|
||||
this.expTextarea.object = object
|
||||
},
|
||||
saveExpTextarea(results:string[]) {
|
||||
this.$props.rule[this.expTextarea.object] = results
|
||||
this.closeExpTextarea()
|
||||
},
|
||||
closeExpTextarea() {
|
||||
this.expTextarea.visible = false
|
||||
this.expTextarea.title = ''
|
||||
this.expTextarea.object = ''
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
optionInbound: {
|
||||
get() { return this.$props.rule.inbound != undefined },
|
||||
set(v:boolean) { this.$props.rule.inbound = v ? [] : undefined }
|
||||
},
|
||||
optionClient: {
|
||||
get() { return this.$props.rule.auth_user != undefined },
|
||||
set(v:boolean) { this.$props.rule.auth_user = v ? [] : undefined }
|
||||
},
|
||||
optionIPver: {
|
||||
get() { return this.$props.rule.ip_version != undefined },
|
||||
set(v:boolean) { this.$props.rule.ip_version = v ? 4 : undefined }
|
||||
},
|
||||
optionProtocol: {
|
||||
get() { return this.$props.rule.protocol != undefined },
|
||||
set(v:boolean) { this.$props.rule.protocol = v ? ['http'] : undefined }
|
||||
},
|
||||
optionDomain: {
|
||||
get() { return Object.keys(this.$props.rule).some(r => this.domainKeys.includes(r)) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.domain = []
|
||||
} else {
|
||||
this.domainKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.domainOption = 'domain'
|
||||
}
|
||||
},
|
||||
optionPort: {
|
||||
get() { return Object.keys(this.$props.rule).some(r => this.portKeys.includes(r)) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.port = []
|
||||
} else {
|
||||
this.portKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.portOption = 'port'
|
||||
}
|
||||
},
|
||||
optionSrcIP: {
|
||||
get() { return Object.keys(this.$props.rule).some(r => this.srcIPKeys.includes(r)) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.source_ip_cidr = []
|
||||
} else {
|
||||
this.srcIPKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.srcIPOption = 'source_ip_cidr'
|
||||
}
|
||||
},
|
||||
optionSrcPort: {
|
||||
get() { return Object.keys(this.$props.rule).some(r => this.srcPortKeys.includes(r)) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.source_port = []
|
||||
} else {
|
||||
this.srcPortKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.srcPortOption = 'source_port'
|
||||
}
|
||||
},
|
||||
optionPreferredBy: {
|
||||
get() { return this.$props.rule.preferred_by != undefined },
|
||||
set(v:boolean) { this.$props.rule.preferred_by = v ? [] : undefined }
|
||||
},
|
||||
optionInterface: {
|
||||
get() { return this.interfaceKeys.some(k => this.$props.rule[k] != undefined) },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.interface_address = []
|
||||
} else {
|
||||
this.interfaceKeys.forEach(k => delete this.$props.rule[k])
|
||||
}
|
||||
this.interfaceOption = 'interface_address'
|
||||
}
|
||||
},
|
||||
optionRuleSet: {
|
||||
get() { return this.$props.rule.rule_set != undefined },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.$props.rule.rule_set = []
|
||||
this.$props.rule.rule_set_ip_cidr_match_source = false
|
||||
} else {
|
||||
delete this.$props.rule.rule_set
|
||||
delete this.$props.rule.rule_set_ip_cidr_match_source
|
||||
}
|
||||
}
|
||||
},
|
||||
optionNetwork: {
|
||||
get() { return this.$props.rule.network != undefined },
|
||||
set(v:boolean) { this.$props.rule.network = v ? [] : undefined }
|
||||
},
|
||||
domain: {
|
||||
get() { return this.$props.rule.domain?.join('\n') ?? '' },
|
||||
set(v:string) { this.$props.rule.domain = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
|
||||
},
|
||||
domain_suffix: {
|
||||
get() { return this.$props.rule.domain_suffix?.join('\n') ?? '' },
|
||||
set(v:string) { this.$props.rule.domain_suffix = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
|
||||
},
|
||||
domain_keyword: {
|
||||
get() { return this.$props.rule.domain_keyword?.join('\n') ?? '' },
|
||||
set(v:string) { this.$props.rule.domain_keyword = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
|
||||
},
|
||||
domain_regex: {
|
||||
get() { return this.$props.rule.domain_regex?.join('\n') ?? '' },
|
||||
set(v:string) { this.$props.rule.domain_regex = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
|
||||
},
|
||||
ip_cidr: {
|
||||
get() { return this.$props.rule.ip_cidr?.join('\n') ?? '' },
|
||||
set(v:string) { this.$props.rule.ip_cidr = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
|
||||
},
|
||||
port: {
|
||||
get() { return this.$props.rule.port?.join('\n') ?? '' },
|
||||
set(v:string) {
|
||||
const lines = v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0)
|
||||
if (!v.endsWith('\n')) {
|
||||
this.$props.rule.port = lines.length > 0 ? lines.map((str:string) => parseInt(str, 10)).filter((n:number) => !isNaN(n)) : []
|
||||
}
|
||||
}
|
||||
},
|
||||
port_range: {
|
||||
get() { return this.$props.rule.port_range?.join('\n') ?? '' },
|
||||
set(v:string) { this.$props.rule.port_range = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
|
||||
},
|
||||
source_ip_cidr: {
|
||||
get() { return this.$props.rule.source_ip_cidr?.join('\n') ?? '' },
|
||||
set(v:string) { this.$props.rule.source_ip_cidr = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
|
||||
},
|
||||
source_port: {
|
||||
get() { return this.$props.rule.source_port?.join('\n') ?? '' },
|
||||
set(v:string) {
|
||||
const lines = v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0)
|
||||
if (!v.endsWith('\n')) {
|
||||
this.$props.rule.source_port = lines.length > 0 ? lines.map((str:string) => parseInt(str, 10)).filter((n:number) => !isNaN(n)) : []
|
||||
}
|
||||
}
|
||||
},
|
||||
source_port_range: {
|
||||
get() { return this.$props.rule.source_port_range?.join('\n') ?? '' },
|
||||
set(v:string) { this.$props.rule.source_port_range = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : [] }
|
||||
},
|
||||
interface_addr: {
|
||||
get() {
|
||||
const k = this.interfaceKeys.find(k => this.$props.rule[k] != undefined)
|
||||
return k ? this.$props.rule[k]?.join('\n') ?? '' : ''
|
||||
},
|
||||
set(v:string) {
|
||||
const k = this.interfaceKeys.find(k => this.$props.rule[k] != undefined)
|
||||
if (k) this.$props.rule[k] = v.length > 0 ? v.split('\n').map((s:string) => s.trim()).filter((s:string) => s.length > 0) : []
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const ruleKeys = Object.keys(this.$props.rule)
|
||||
if (this.optionDomain) {
|
||||
const enabledOption = this.domainKeys.filter(k => ruleKeys.includes(k))
|
||||
this.domainOption = enabledOption.length>0 ? enabledOption[0] : 'domain'
|
||||
}
|
||||
if (this.optionPort) {
|
||||
const enabledOption = this.portKeys.filter(k => ruleKeys.includes(k))
|
||||
this.portOption = enabledOption.length>0 ? enabledOption[0] : 'port'
|
||||
}
|
||||
if (this.optionSrcIP) {
|
||||
const enabledOption = this.srcIPKeys.filter(k => ruleKeys.includes(k))
|
||||
this.srcIPOption = enabledOption.length>0 ? enabledOption[0] : 'source_ip_cidr'
|
||||
}
|
||||
if (this.optionSrcPort) {
|
||||
const enabledOption = this.srcPortKeys.filter(k => ruleKeys.includes(k))
|
||||
this.srcPortOption = enabledOption.length>0 ? enabledOption[0] : 'source_port'
|
||||
}
|
||||
if (this.optionInterface) {
|
||||
const enabledOption = this.interfaceKeys.filter(k => ruleKeys.includes(k))
|
||||
this.interfaceOption = enabledOption.length>0 ? enabledOption[0] : 'interface_address'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" class="v-card-subtitle" style="margin-top: -5px;">{{ label }}</v-col>
|
||||
<v-col :cols="data.type == 'local' ? 12 : 4">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('type')"
|
||||
:items="['udp','tcp','local','tls','quic','h3']"
|
||||
@update:model-value="updateType($event)"
|
||||
density="compact"
|
||||
:class="data.type != 'local' ? 'noGutters' : ''"
|
||||
v-model="data.type">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="5" v-if="data.type != 'local'">
|
||||
<v-text-field
|
||||
v-model="data.server"
|
||||
:label="$t('in.addr')"
|
||||
density="compact"
|
||||
class="noGutters"
|
||||
hide-details>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="3" v-if="data.type != 'local'">
|
||||
<v-text-field
|
||||
v-model.number="data.server_port"
|
||||
:label="$t('in.port')"
|
||||
density="compact"
|
||||
type="number"
|
||||
class="noGutters"
|
||||
min="1"
|
||||
hide-details>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: ['data', 'label'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
updateType(t:string) {
|
||||
if (t == 'local') {
|
||||
delete this.data.server
|
||||
delete this.data.server_port
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.noGutters .v-field__input,
|
||||
.noGutters .v-field {
|
||||
text-align: center !important;
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<Editor
|
||||
v-model="enableEditor"
|
||||
:data="settings.subClashExt"
|
||||
:visible="enableEditor"
|
||||
:title="$t('editor') + ' - ' + $t('setting.clashSub')"
|
||||
@close="enableEditor = false"
|
||||
@save="saveEditor"
|
||||
/>
|
||||
<v-card>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionMixed">
|
||||
<v-text-field type="number" v-model.number="mixedPort" min="1" max="65535" :label="$t('setting.mixedPort')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionMixed">
|
||||
<v-switch color="primary" v-model="allowLan" :label="$t('types.ts.allowLanAccess')" hide-details />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionExt">
|
||||
<v-text-field v-model="externalController" :label="$t('basic.exp.extController')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionLog">
|
||||
<v-select v-model="logLevel" :items="['debug', 'info', 'warning', 'error']" :label="$t('basic.log.title') + ' - ' + $t('basic.log.level')" hide-details></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionTun">
|
||||
<v-switch color="primary" v-model="tun" :label="$t('setting.tun')" hide-details />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3" lg="2" v-if="optionDns">
|
||||
<v-switch color="primary" v-model="dns" :label="$t('pages.dns')" hide-details />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="optionRules">
|
||||
<v-col cols="12" sm="12" md="6" lg="4">
|
||||
<v-select
|
||||
v-model="rules"
|
||||
:items="rulesIP"
|
||||
chips
|
||||
closable-chips
|
||||
multiple
|
||||
hide-details
|
||||
:label="$t('pages.rules')"
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="openEditor" variant="outlined" hide-details>{{ $t('editor') }}</v-btn>
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('setting.jsonSubOptions') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionMixed" color="primary" :label="$t('setting.mixedPort')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionTun" color="primary" :label="$t('setting.tun')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionExt" color="primary" :label="$t('basic.exp.extController')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionLog" color="primary" :label="$t('basic.log.title')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionDns" color="primary" :label="$t('pages.dns')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionRules" color="primary" :label="$t('pages.rules')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { push } from 'notivue'
|
||||
import Editor from './Editor.vue'
|
||||
import yaml from 'yaml'
|
||||
import { i18n } from '@/locales'
|
||||
export default {
|
||||
props: ['settings'],
|
||||
data() {
|
||||
return {
|
||||
enableEditor: false,
|
||||
menu: false,
|
||||
defaultConfig: {
|
||||
"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"
|
||||
]
|
||||
},
|
||||
rulesIP: [
|
||||
{ title: 'Private-Direct', value: 'GEOIP,Private,DIRECT' },
|
||||
{ title: 'Private-Block', value: 'GEOIP,Private,REJECT' },
|
||||
{ title: 'LAN-Direct', value: 'GEOIP,LAN,DIRECT' },
|
||||
{ title: 'LAN-Block', value: 'GEOIP,LAN,REJECT' },
|
||||
{ title: 'Ads-Direct', value: 'GEOIP,Ads,DIRECT' },
|
||||
{ title: 'Ads-Block', value: 'GEOIP,Ads,REJECT' },
|
||||
{ title: '🇨🇳 China-Direct', value: 'GEOIP,CN,DIRECT' },
|
||||
{ title: '🇨🇳 China-Block', value: 'GEOIP,CN,REJECT' },
|
||||
{ title: '🇮🇷 Iran-Direct', value: 'GEOIP,CATEGORY-IR,DIRECT' },
|
||||
{ title: '🇮🇷 Iran-Block', value: 'GEOIP,CATEGORY-IR,REJECT' },
|
||||
{ title: '🇻🇳 Vietnam-Direct', value: 'GEOIP,CATEGORY-VN,DIRECT' },
|
||||
{ title: '🇻🇳 Vietnam-Block', value: 'GEOIP,CATEGORY-VN,REJECT' },
|
||||
{ title: '🇯🇵 Japan-Direct', value: 'GEOIP,JP,DIRECT' },
|
||||
{ title: '🇯🇵 Japan-Block', value: 'GEOIP,JP,REJECT' },
|
||||
],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openEditor() {
|
||||
this.enableEditor = true
|
||||
},
|
||||
saveEditor(data:string) {
|
||||
try {
|
||||
const result = yaml.parse(data)
|
||||
if (typeof result != 'object' || Array.isArray(result)) throw new Error()
|
||||
} catch (e) {
|
||||
push.error({
|
||||
message: i18n.global.t('failed') + ": " + i18n.global.t('error.invalidData'),
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
this.$props.settings.subClashExt = data
|
||||
this.enableEditor = false
|
||||
},
|
||||
updateMetaJson(data:any, key:string) {
|
||||
let newMetaJson = this.metaJson
|
||||
if (data==null) {
|
||||
delete newMetaJson[key]
|
||||
} else {
|
||||
newMetaJson[key] = data
|
||||
}
|
||||
this.metaJson = newMetaJson
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
metaJson: {
|
||||
get() {
|
||||
try {
|
||||
return yaml.parse(this.settings.subClashExt)??{}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
set(v:any) {
|
||||
this.settings.subClashExt = Object.keys(v).length==0 ? "" : yaml.stringify(v)
|
||||
}
|
||||
},
|
||||
optionMixed: {
|
||||
get() { return this.metaJson['mixed-port']>0 },
|
||||
set(v:boolean) {
|
||||
this.updateMetaJson(v ? this.defaultConfig['mixed-port'] : null, 'mixed-port')
|
||||
this.updateMetaJson(v ? this.defaultConfig['allow-lan'] : null, 'allow-lan')
|
||||
}
|
||||
},
|
||||
optionTun: {
|
||||
get() { return this.metaJson['tun']?.['enable']?? false },
|
||||
set(v:boolean) { this.updateMetaJson(v ? this.defaultConfig['tun'] : null, 'tun') }
|
||||
},
|
||||
optionExt: {
|
||||
get() { return this.metaJson['external-controller']?.length>0 },
|
||||
set(v:boolean) { this.updateMetaJson(v ? this.defaultConfig['external-controller'] : null, 'external-controller') }
|
||||
},
|
||||
optionLog: {
|
||||
get() { return this.metaJson['log-level']?.length>0 },
|
||||
set(v:boolean) { this.updateMetaJson(v ? this.defaultConfig['log-level'] : null, 'log-level') }
|
||||
},
|
||||
optionDns: {
|
||||
get() { return this.metaJson['dns']?.['enable']?? false },
|
||||
set(v:boolean) { this.updateMetaJson(v ? this.defaultConfig['dns'] : null, 'dns') }
|
||||
},
|
||||
optionRules: {
|
||||
get() { return this.metaJson['rules']?.length>0 },
|
||||
set(v:boolean) {
|
||||
this.updateMetaJson(v ? this.defaultConfig['rules'] : null, 'rules')
|
||||
this.updateMetaJson(v ? this.defaultConfig['mode'] : null, 'mode')
|
||||
}
|
||||
},
|
||||
mixedPort: {
|
||||
get() { return this.metaJson['mixed-port'] },
|
||||
set(v:number) { this.updateMetaJson(v, 'mixed-port') }
|
||||
},
|
||||
allowLan: {
|
||||
get() { return this.metaJson['allow-lan'] },
|
||||
set(v:boolean) { this.updateMetaJson(v, 'allow-lan') }
|
||||
},
|
||||
externalController: {
|
||||
get() { return this.metaJson['external-controller'] },
|
||||
set(v:string) { this.updateMetaJson(v, 'external-controller') }
|
||||
},
|
||||
logLevel: {
|
||||
get() { return this.metaJson['log-level'] },
|
||||
set(v:string) { this.updateMetaJson(v, 'log-level') }
|
||||
},
|
||||
dns: {
|
||||
get() { return this.metaJson['dns']?.['enable'] ?? false },
|
||||
set(v:boolean) { this.updateMetaJson({ ...this.metaJson['dns'], 'enable': v }, 'dns') }
|
||||
},
|
||||
tun: {
|
||||
get() { return this.metaJson['tun']?.['enable'] ?? false },
|
||||
set(v:boolean) { this.updateMetaJson({ ...this.metaJson['tun'], 'enable': v }, 'tun') }
|
||||
},
|
||||
rules: {
|
||||
get() { return this.metaJson.rules.length > 0 ? this.metaJson.rules.filter((r:string) => r != "MATCH,Proxy") : [] },
|
||||
set(v:string[]) {
|
||||
let newRules = <string[]>[]
|
||||
v.forEach((r:string) => { newRules.push(r) })
|
||||
this.updateMetaJson([ ...newRules, "MATCH,Proxy" ], 'rules')
|
||||
}
|
||||
}
|
||||
},
|
||||
components: { Editor }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<Editor
|
||||
v-model="enableEditor"
|
||||
:data="settings.subJsonExt"
|
||||
:visible="enableEditor"
|
||||
:title="$t('editor') + ' - ' + $t('setting.jsonSub')"
|
||||
@close="enableEditor = false"
|
||||
@save="saveEditor"
|
||||
/>
|
||||
<v-card>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-select
|
||||
v-model="ruleToDirect"
|
||||
:items="geoList"
|
||||
:label="$t('setting.toDirect')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-select
|
||||
v-model="ruleToBlock"
|
||||
:items="geoList"
|
||||
:label="$t('setting.toBlock')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="enableLog">
|
||||
<v-col cols="12" sm="6" md="3" lg="2">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('basic.log.level')"
|
||||
:items="levels"
|
||||
v-model="subJsonExt.log.level">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3" lg="2">
|
||||
<v-switch v-model="subJsonExt.log.timestamp" color="primary" :label="$t('setting.timestamp')" hide-details />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="enableDns">
|
||||
<v-col cols="12" sm="6" md="3" lg="2">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('dns.final')"
|
||||
:items="dnsTags"
|
||||
v-model="subJsonExt.dns.final">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3" lg="2">
|
||||
<SimpleDNS :data="proxyDns" :label="$t('setting.globalDns')" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3" lg="2">
|
||||
<SimpleDNS :data="directDns" :label="$t('setting.directDns')" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="enableDns">
|
||||
<v-col cols="12" sm="6" md="3" lg="2">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('basic.routing.defaultDns')"
|
||||
:items="dnsTags"
|
||||
clearable
|
||||
@click:clear="delete subJsonExt.default_domain_resolver"
|
||||
v-model="subJsonExt.default_domain_resolver">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-select
|
||||
v-model="dnsToDirect"
|
||||
:items="geositeList"
|
||||
:label="$t('setting.toDirectDns')"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<template v-if="enableInb">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-combobox
|
||||
v-model="inbounds[0].address"
|
||||
:items="defaultInb[0].address"
|
||||
chips
|
||||
multiple
|
||||
hide-details
|
||||
:label="$t('in.addr')"
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3" lg="2">
|
||||
<v-text-field
|
||||
type="number"
|
||||
v-model.number="inbounds[0].mtu"
|
||||
hide-details
|
||||
label="MTU"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-combobox
|
||||
v-model="inbounds[0].exclude_package"
|
||||
:items="['ir.mci.ecareapp','com.myirancell']"
|
||||
chips
|
||||
multiple
|
||||
hide-details
|
||||
:label="$t('setting.excludePkg')"
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3" lg="2">
|
||||
<v-switch
|
||||
v-model="platformProxy"
|
||||
hide-details
|
||||
color="primary"
|
||||
label="Platform HTTP proxy"
|
||||
></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="openEditor" variant="outlined" hide-details>{{ $t('editor') }}</v-btn>
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('setting.jsonSubOptions') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch v-model="enableLog" color="primary" :label="$t('basic.log.title')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="enableDns" color="primary" label="DNS" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="enableInb" color="primary" :label="$t('objects.inbound')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="enableExp" color="primary" label="Experimental" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Editor from './Editor.vue'
|
||||
import SimpleDNS from './SimpleDNS.vue'
|
||||
import { push } from 'notivue'
|
||||
import { i18n } from '@/locales'
|
||||
export default {
|
||||
props: ['settings'],
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
enableEditor: false,
|
||||
subJsonExt: <any>{},
|
||||
levels: ["trace", "debug", "info", "warn", "error", "fatal", "panic"],
|
||||
defaultLog: {
|
||||
"level": "info",
|
||||
"timestamp": true
|
||||
},
|
||||
defaultInb: [
|
||||
{
|
||||
"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": "mixed",
|
||||
"exclude_package": [],
|
||||
"platform": {
|
||||
"http_proxy": {
|
||||
"enabled": true,
|
||||
"server": "127.0.0.1",
|
||||
"server_port": 2080
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "mixed",
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": 2080,
|
||||
"users": []
|
||||
}
|
||||
],
|
||||
defaultExp: {
|
||||
"clash_api": {
|
||||
"external_controller": "127.0.0.1:9090",
|
||||
"external_ui": "ui",
|
||||
"secret": "",
|
||||
"external_ui_download_url": "https://mirror.ghproxy.com/https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip",
|
||||
"external_ui_download_detour": "direct",
|
||||
"default_mode": "rule"
|
||||
},
|
||||
"cache_file": {
|
||||
"enabled": true,
|
||||
"store_fakeip": false
|
||||
}
|
||||
},
|
||||
defaultDns: {
|
||||
"servers": [
|
||||
{
|
||||
"type": "tcp",
|
||||
"tag": "proxy-dns",
|
||||
"server": "8.8.8.8",
|
||||
"server_port": 53,
|
||||
"detour": "proxy",
|
||||
"domain_resolver": "local-dns",
|
||||
},
|
||||
{
|
||||
"tag": "direct-dns",
|
||||
"type": "local",
|
||||
},
|
||||
{
|
||||
"tag": "local-dns",
|
||||
"type": "local",
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"clash_mode": "Global",
|
||||
"source_ip_cidr": [
|
||||
"172.19.0.0/30",
|
||||
"fdfe:dcba:9876::1/126"
|
||||
],
|
||||
"action": "route",
|
||||
"server": "proxy-dns"
|
||||
},
|
||||
{
|
||||
"clash_mode": "Direct",
|
||||
"action": "route",
|
||||
"server": "direct-dns"
|
||||
},
|
||||
{
|
||||
"source_ip_cidr": [
|
||||
"172.19.0.0/30",
|
||||
"fdfe:dcba:9876::1/126"
|
||||
],
|
||||
"action": "route",
|
||||
"server": "proxy-dns"
|
||||
},
|
||||
],
|
||||
"final": "local-dns",
|
||||
"strategy": "prefer_ipv4"
|
||||
},
|
||||
geositeList: [
|
||||
{ title: "Private", value: "geosite-private" },
|
||||
{ title: "Ads", value: "geosite-ads" },
|
||||
{ title: "🇮🇷 Iran", value: "geosite-ir" },
|
||||
{ title: "🇨🇳 China", value: "geosite-cn" },
|
||||
{ title: "🇻🇳 Vietnam", value: "geosite-vn" },
|
||||
],
|
||||
geoList: [
|
||||
{ title: "Site-Private", value: "geoip-private" },
|
||||
{ title: "IP-Private", value: "geosite-private" },
|
||||
{ title: "Site-Ads", value: "geosite-ads" },
|
||||
{ title: "🇮🇷 Site-Iran", value: "geosite-ir" },
|
||||
{ title: "🇮🇷 IP-Iran", value: "geoip-ir" },
|
||||
{ title: "🇨🇳 Site-China", value: "geosite-cn" },
|
||||
{ title: "🇨🇳 IP-China", value: "geoip-cn" },
|
||||
{ title: "🇻🇳 Site-Vietnam", value: "geosite-vn" },
|
||||
{ title: "🇻🇳 IP-Vietnam", value: "geoip-vn" },
|
||||
],
|
||||
geo: [
|
||||
{
|
||||
tag: "geosite-ads",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/category-ads-all.srs",
|
||||
download_detour: "direct"
|
||||
},
|
||||
{
|
||||
tag: "geosite-private",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/private.srs",
|
||||
download_detour: "direct"
|
||||
},
|
||||
{
|
||||
tag: "geosite-ir",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/category-ir.srs",
|
||||
download_detour: "direct"
|
||||
},
|
||||
{
|
||||
tag: "geosite-cn",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/cn.srs",
|
||||
download_detour: "direct"
|
||||
},
|
||||
{
|
||||
tag: "geosite-vn",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://github.com/Thaomtam/Geosite-vn/raw/rule-set/Geosite-vn.srs",
|
||||
download_detour: "direct"
|
||||
},
|
||||
{
|
||||
tag: "geoip-private",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/private.srs",
|
||||
download_detour: "direct"
|
||||
},
|
||||
{
|
||||
tag: "geoip-ir",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/ir.srs",
|
||||
download_detour: "direct"
|
||||
},
|
||||
{
|
||||
tag: "geoip-cn",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/cn.srs",
|
||||
download_detour: "direct"
|
||||
},
|
||||
{
|
||||
tag: "geoip-vn",
|
||||
type: "remote",
|
||||
format: "binary",
|
||||
url: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/vn.srs",
|
||||
download_detour: "direct"
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
enableLog: {
|
||||
get() :boolean { return this.subJsonExt?.log != undefined },
|
||||
set(v:boolean) { v ? this.subJsonExt.log = this.defaultLog : delete this.subJsonExt.log }
|
||||
},
|
||||
enableDns: {
|
||||
get() :boolean { return this.subJsonExt?.dns != undefined },
|
||||
set(v:boolean) {
|
||||
if (v) {
|
||||
this.subJsonExt.dns = this.defaultDns
|
||||
if (this.rules == undefined) this.subJsonExt.rules = [{ action: 'sniff' }]
|
||||
this.subJsonExt.rules.unshift({ protocol: "dns", action: "hijack-dns" })
|
||||
} else {
|
||||
delete this.subJsonExt.dns
|
||||
const rules = this.subJsonExt?.rules?.filter((r:any) => r.protocol != "dns") ?? []
|
||||
if (rules.length >= 0) this.subJsonExt.rules = rules
|
||||
if (this.rules.length == 0) delete this.subJsonExt.rules
|
||||
}
|
||||
}
|
||||
},
|
||||
enableInb: {
|
||||
get() :boolean { return this.subJsonExt?.inbounds != undefined },
|
||||
set(v:boolean) { v ? this.subJsonExt.inbounds = this.defaultInb.slice() : delete this.subJsonExt.inbounds }
|
||||
},
|
||||
enableExp: {
|
||||
get() :boolean { return this.subJsonExt?.experimental != undefined },
|
||||
set(v:boolean) { v ? this.subJsonExt.experimental = this.defaultExp : delete this.subJsonExt.experimental }
|
||||
},
|
||||
dns():any { return this.subJsonExt?.dns?? undefined },
|
||||
proxyDns: {
|
||||
get() :any { return this.dns?.servers?.findLast((d:any) => d.tag == "proxy-dns")?? {} },
|
||||
set(v:any) {
|
||||
let sIndex = this.dns.servers.findIndex((d:any) => d.tag == "proxy-dns")
|
||||
if (sIndex === -1 || sIndex == undefined) {
|
||||
this.dns.servers.push({ ...this.defaultDns.servers[0], ...v })
|
||||
} else {
|
||||
this.dns.servers[sIndex] = { ...this.defaultDns.servers[0], ...v }
|
||||
}
|
||||
}
|
||||
},
|
||||
directDns: {
|
||||
get() :any { return this.dns?.servers?.findLast((d:any) => d.tag == "direct-dns")?? {} },
|
||||
set(v:any) {
|
||||
const sIndex = this.dns.servers.findIndex((d:any) => d.tag == "direct-dns")
|
||||
if (sIndex === -1 || sIndex == undefined) {
|
||||
this.dns.servers.push({ ...this.defaultDns.servers[1], ...v })
|
||||
} else {
|
||||
this.dns.servers[sIndex] = { ...this.defaultDns.servers[1], ...v }
|
||||
}
|
||||
},
|
||||
},
|
||||
dnsTags() { return this.dns?.servers?.map((d:any) => d.tag) ?? [] },
|
||||
final: {
|
||||
get() :string { return this.dns.final?? "" },
|
||||
set(v:string) { this.dns.final = v.length>0 ? v : undefined }
|
||||
},
|
||||
dnsToDirect: {
|
||||
get() :string[] {
|
||||
const ruleIndex = this.dns?.rules?.findIndex((r:any) => r.server == "direct-dns" && Object.hasOwn(r,'rule_set'))
|
||||
return ruleIndex >= 0 ? this.dns.rules[ruleIndex].rule_set : []
|
||||
},
|
||||
set(v:string[]) {
|
||||
const ruleIndex = this.dns?.rules?.findIndex((r:any) => r.server == "direct-dns" && Object.hasOwn(r,'rule_set'))
|
||||
if (v.length>0) {
|
||||
if (ruleIndex >= 0){
|
||||
this.dns.rules[ruleIndex].rule_set = v
|
||||
} else {
|
||||
this.dns.rules.push({ rule_set: v, action: "route", server: "direct-dns" })
|
||||
}
|
||||
} else {
|
||||
if (ruleIndex != -1) this.dns.rules.splice(ruleIndex,1)
|
||||
}
|
||||
this.updateRuleSets()
|
||||
}
|
||||
},
|
||||
inbounds():any[] { return this.subJsonExt?.inbounds?? undefined },
|
||||
platformProxy: {
|
||||
get() :boolean { return this.inbounds[0]?.platform != undefined },
|
||||
set(v:boolean) { this.subJsonExt.inbounds[0].platform = v ? this.defaultInb[0].platform : undefined }
|
||||
},
|
||||
rules():any { return this.subJsonExt?.rules?? undefined },
|
||||
ruleToDirect: {
|
||||
get() :string[] {
|
||||
const ruleIndex = this.rules?.findIndex((r:any) => r.outbound == "direct" && Object.hasOwn(r,'rule_set'))
|
||||
return ruleIndex >= 0 ? this.rules[ruleIndex].rule_set : []
|
||||
},
|
||||
set(v:string[]) {
|
||||
const ruleIndex = this.rules?.findIndex((r:any) => r.outbound == "direct" && Object.hasOwn(r,'rule_set'))
|
||||
if (v.length>0) {
|
||||
if (ruleIndex >= 0){
|
||||
this.rules[ruleIndex].rule_set = v
|
||||
} else {
|
||||
if (this.rules == undefined) this.subJsonExt.rules = []
|
||||
this.rules.push({ rule_set: v, action: "route", outbound: "direct" })
|
||||
}
|
||||
} else {
|
||||
if (ruleIndex != -1) this.rules.splice(ruleIndex,1)
|
||||
}
|
||||
this.updateRuleSets()
|
||||
}
|
||||
},
|
||||
ruleToBlock: {
|
||||
get() :string[] {
|
||||
const ruleIndex = this.rules?.findIndex((r:any) => r.action == "reject" && Object.hasOwn(r,'rule_set'))
|
||||
return ruleIndex >= 0 ? this.rules[ruleIndex].rule_set : []
|
||||
},
|
||||
set(v:string[]) {
|
||||
const ruleIndex = this.rules?.findIndex((r:any) => r.action == "reject" && Object.hasOwn(r,'rule_set'))
|
||||
if (v.length>0) {
|
||||
if (ruleIndex >= 0){
|
||||
this.rules[ruleIndex].rule_set = v
|
||||
} else {
|
||||
if (this.rules == undefined) this.subJsonExt.rules = []
|
||||
this.rules.push({ rule_set: v, action: "reject" })
|
||||
}
|
||||
} else {
|
||||
if (ruleIndex != -1) this.rules.splice(ruleIndex,1)
|
||||
}
|
||||
this.updateRuleSets()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
if (this.$props.settings?.subJsonExt?.length>0){
|
||||
this.subJsonExt = JSON.parse(this.$props.settings.subJsonExt)
|
||||
} else {
|
||||
this.subJsonExt = <any>{}
|
||||
}
|
||||
},
|
||||
updateRuleSets(){
|
||||
let tags = <string[]>[]
|
||||
if (this.dns?.rules?.length>0) this.dns.rules.forEach((r:any) => { if (r.rule_set) tags.push(...r.rule_set) })
|
||||
if (this.rules?.length>0) this.rules.forEach((r:any) => { if (r.rule_set) tags.push(...r.rule_set) })
|
||||
if (tags.length>0){
|
||||
this.subJsonExt.rule_set = this.geo.filter((g:any) => tags.includes(g.tag))
|
||||
} else {
|
||||
delete this.subJsonExt.rule_set
|
||||
}
|
||||
if (this.rules.length == 0) delete this.subJsonExt.rules
|
||||
},
|
||||
openEditor() {
|
||||
this.enableEditor = true
|
||||
},
|
||||
saveEditor(data:string) {
|
||||
try {
|
||||
this.subJsonExt = JSON.parse(data)
|
||||
} catch (e) {
|
||||
push.error({
|
||||
message: i18n.global.t('failed') + ": " + i18n.global.t('error.invalidData'),
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
this.enableEditor = false
|
||||
}
|
||||
},
|
||||
mounted(){
|
||||
this.loadData()
|
||||
},
|
||||
watch:{
|
||||
subJsonExt:{
|
||||
handler(v) {
|
||||
this.$props.settings.subJsonExt = Object.keys(v).length>0 ? JSON.stringify(v, null, 2) : ""
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
},
|
||||
components: { Editor, SimpleDNS }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<v-card :subtitle="$t('objects.transport')">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch color="primary" :label="$t('transport.enable')" v-model="tpEnable" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="tpEnable">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('type')"
|
||||
:items="Object.keys(trspTypes).map((key,index) => ({title: key, value: Object.values(trspTypes)[index]}))"
|
||||
v-model="transportType">
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<Http v-if="Transport.type == trspTypes.HTTP" :transport="Transport" />
|
||||
<WebSocket v-if="Transport.type == trspTypes.WebSocket" :transport="Transport" />
|
||||
<GRPC v-if="Transport.type == trspTypes.gRPC" :transport="Transport" />
|
||||
<HttpUpgrade v-if="Transport.type == trspTypes.HTTPUpgrade" :transport="Transport" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { TrspTypes, Transport } from '@/types/transport'
|
||||
import Http from './transports/Http.vue'
|
||||
import WebSocket from './transports/WebSocket.vue'
|
||||
import GRPC from './transports/gRPC.vue'
|
||||
import HttpUpgrade from './transports/HttpUpgrade.vue'
|
||||
export default {
|
||||
props: ['data'],
|
||||
data() {
|
||||
return {
|
||||
trspTypes: TrspTypes
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
Transport() {
|
||||
return <Transport>this.$props.data.transport
|
||||
},
|
||||
tpEnable: {
|
||||
get() { return Object.hasOwn(this.$props.data.transport, 'type') },
|
||||
set(newValue: boolean) { this.$props.data.transport = newValue ? { type: 'http' } : {} }
|
||||
},
|
||||
transportType: {
|
||||
get() { return this.Transport.type },
|
||||
set(newValue: string) { this.$props.data.transport = { type: newValue } }
|
||||
}
|
||||
},
|
||||
components: { Http, WebSocket, GRPC, HttpUpgrade }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<v-select
|
||||
hide-details
|
||||
label="UDP over TCP"
|
||||
:items="versions"
|
||||
v-model="udp_over_tcp">
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: ['data'],
|
||||
data() {
|
||||
return {
|
||||
versions: [
|
||||
{ title: this.$t('disable'), value: 0 },
|
||||
{ title: "1", value: 1 },
|
||||
{ title: "2", value: 2 },
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
udp_over_tcp: {
|
||||
get():number { return this.$props.data.udp_over_tcp?.version?? 0 },
|
||||
set(v:number) { this.$props.data.udp_over_tcp = v > 0 ? { enabled: true, version: v } : undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<v-card :subtitle="$t('pages.clients')">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select v-model="data.model" :items="initUsersModels" @update:model-value="data.values = []" hide-details></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="data.model == 'group'">
|
||||
<v-select v-model="data.values" multiple chips :items="groupNames" :label="$t('client.group')" hide-details></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="8" v-if="data.model == 'client'">
|
||||
<v-select v-model="data.values" multiple chips :items="clientNames" :label="$t('pages.clients')" hide-details></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { i18n } from '@/locales'
|
||||
|
||||
|
||||
export default {
|
||||
props: ['data', 'clients'],
|
||||
data() {
|
||||
return {
|
||||
initUsersModels: [
|
||||
{ title: i18n.global.t('none'), value: 'none' },
|
||||
{ title: i18n.global.t('all'), value: 'all' },
|
||||
{ title: i18n.global.t('client.group'), value: 'group' },
|
||||
{ title: i18n.global.t('pages.clients'), value: 'client' },
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
clientNames() {
|
||||
return this.$props.clients.map((c:any) => { return { title: c.name, value: c.id } } )
|
||||
},
|
||||
groupNames() {
|
||||
return Array.from(new Set(this.$props.clients.map((c:any) => c.group)))
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="8">
|
||||
<v-text-field
|
||||
v-model="privateKey"
|
||||
:label="$t('types.wg.privKey')"
|
||||
append-icon="mdi-key-star"
|
||||
@click:append="refreshKey"
|
||||
hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="8">
|
||||
<v-text-field v-model="publicKey" :label="$t('types.wg.pubKey')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="8">
|
||||
<v-text-field v-model="data.pre_shared_key" :label="$t('types.wg.psk')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('out.addr')"
|
||||
hide-details
|
||||
v-model="address">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('out.port')"
|
||||
type="number"
|
||||
min="0"
|
||||
hide-details
|
||||
v-model.number="port">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
label="KeepAlive"
|
||||
type="number"
|
||||
min="0"
|
||||
:suffix="$t('date.s')"
|
||||
hide-details
|
||||
v-model.number="keepAlive">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field v-model="allowed_ips" :label="$t('types.wg.allowedIp') + ' ' + $t('commaSeparated')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field v-model="reserved" :label="'Reserved ' + $t('commaSeparated')" hide-details></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: ['data', 'ext'],
|
||||
emits: ['refreshPeerKey'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
refreshKey() {
|
||||
this.$emit('refreshPeerKey')
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allowed_ips: {
|
||||
get() { return this.$props.data.allowed_ips?.join(',') },
|
||||
set(v:string) { this.$props.data.allowed_ips = v.length > 0 ? v.split(',') : undefined }
|
||||
},
|
||||
reserved: {
|
||||
get() { return this.$props.data.reserved?.join(',') },
|
||||
set(v:string) {
|
||||
if(!v.endsWith(',')) {
|
||||
this.$props.data.reserved = v.length > 0 ? v.split(',').map(str => parseInt(str, 10)) : undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
address: {
|
||||
get() { return this.$props.data.address },
|
||||
set(v:string) { this.$props.data.address = v.length > 0 ? v : undefined }
|
||||
},
|
||||
port: {
|
||||
get() { return this.$props.data.port },
|
||||
set(v:number) { this.$props.data.port = v > 0 ? v : undefined }
|
||||
},
|
||||
keepAlive: {
|
||||
get() { return this.$props.data.persistent_keepalive_interval?? 0 },
|
||||
set(v:number) { this.$props.data.persistent_keepalive_interval = v > 0 ? v : undefined }
|
||||
},
|
||||
privateKey: {
|
||||
get() {
|
||||
const indexKeys = this.$props.ext?.keys.findIndex((key: any) => key.public_key == this.$props.data.public_key)?? -1
|
||||
return indexKeys > -1 ? this.$props.ext.keys[indexKeys].private_key : ''
|
||||
},
|
||||
set(v:string) {
|
||||
const indexKeys = this.$props.ext?.keys.findIndex((key: any) => key.public_key == this.$props.data.public_key)?? -1
|
||||
this.$props.ext.keys[indexKeys].private_key = v
|
||||
}
|
||||
},
|
||||
publicKey: {
|
||||
get() { return this.$props.data.public_key },
|
||||
set(v:string) {
|
||||
const indexKeys = this.$props.ext?.keys.findIndex((key: any) => key.public_key == this.$props.data.public_key)?? -1
|
||||
this.$props.ext.keys[indexKeys].public_key = v
|
||||
this.$props.data.public_key = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<Notivue v-slot="item">
|
||||
<NotivueSwipe :item="item">
|
||||
<Notification
|
||||
:item="item"
|
||||
:theme="theme"
|
||||
:dir="direction"
|
||||
:icons="outlinedIcons"
|
||||
:hideClose="true"
|
||||
@click="item.clear"
|
||||
/>
|
||||
</NotivueSwipe>
|
||||
</Notivue>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Notivue, Notification, NotivueSwipe, outlinedIcons, pastelTheme, darkTheme } from 'notivue'
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
|
||||
const Theme = useTheme()
|
||||
|
||||
const theme = computed(() =>{
|
||||
let currenTheme = Theme.global.name.value == "light" ? pastelTheme : darkTheme
|
||||
currenTheme = {
|
||||
...currenTheme,
|
||||
'--nv-width': 'auto',
|
||||
}
|
||||
return currenTheme
|
||||
})
|
||||
|
||||
const direction = computed(() => {
|
||||
return vuetify.locale.isRtl ? 'rtl' : 'ltr'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--nv-z: 10020;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-subtitle v-if="direction != 'out_json'">AnyTls</v-card-subtitle>
|
||||
<v-row v-if="direction == 'in'">
|
||||
<v-col cols="12" sm="8">
|
||||
<v-textarea
|
||||
label="Padding scheme"
|
||||
auto-grow
|
||||
hide-details
|
||||
v-model="padding_scheme">
|
||||
</v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-else>
|
||||
<v-col cols="12" sm="8" v-if="direction == 'out'">
|
||||
<v-text-field
|
||||
:label="$t('types.pw')"
|
||||
hide-details
|
||||
v-model="data.password">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.anytls.idleInterval')"
|
||||
type="number"
|
||||
min="0"
|
||||
hide-details
|
||||
:suffix="$t('date.s')"
|
||||
v-model.number="idleInterval">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.anytls.idleTimeout')"
|
||||
type="number"
|
||||
min="0"
|
||||
hide-details
|
||||
:suffix="$t('date.s')"
|
||||
v-model.number="idleTimeout">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.anytls.minIdle')"
|
||||
type="number"
|
||||
min="0"
|
||||
hide-details
|
||||
v-model.number="minIdle">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: ['data', 'direction'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
padding_scheme: {
|
||||
get() { return this.data.padding_scheme?.length > 0 ? this.data.padding_scheme.join("\n") : '' },
|
||||
set(v:string) { this.data.padding_scheme = v.length > 0 ? v.split("\n") : undefined }
|
||||
},
|
||||
idleInterval: {
|
||||
get() { return this.data.idle_session_check_interval?.length > 0 ? parseInt(this.data.idle_session_check_interval.replace('s','')) : 30 },
|
||||
set(v:number) { this.data.idle_session_check_interval = v && v >= 0 ? `${v}s` : undefined }
|
||||
},
|
||||
idleTimeout: {
|
||||
get() { return this.data.idle_session_timeout?.length > 0 ? parseInt(this.data.idle_session_timeout.replace('s','')) : 30 },
|
||||
set(v:number) { this.data.idle_session_timeout = v && v >= 0 ? `${v}s` : undefined }
|
||||
},
|
||||
minIdle: {
|
||||
get() { return this.data.min_idle_session != undefined ? this.data.min_idle_session : 0 },
|
||||
set(v:number) { this.data.min_idle_session = v>0 ? v : undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<v-card subtitle="Direct">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<Network :data="data" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.direct.overrideAddr')"
|
||||
hide-details
|
||||
v-model="data.override_address">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.direct.overridePort')"
|
||||
type="number"
|
||||
min="0"
|
||||
hide-details
|
||||
v-model.number="override_port">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Network from '@/components/Network.vue'
|
||||
|
||||
export default {
|
||||
props: ['data'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
override_port: {
|
||||
get() { return this.$props.data.override_port ? this.$props.data.override_port : '' },
|
||||
set(newValue: any) { this.$props.data.override_port = newValue.length == 0 || newValue == 0 ? undefined : parseInt(newValue) }
|
||||
},
|
||||
},
|
||||
components: { Network }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<v-card subtitle="HTTP">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.un')"
|
||||
hide-details
|
||||
v-model="username">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.pw')"
|
||||
hide-details
|
||||
v-model="password">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('transport.path')"
|
||||
hide-details
|
||||
v-model="data.path">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<Headers :data="data" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Headers from '@/components/Headers.vue'
|
||||
|
||||
export default {
|
||||
props: ['data'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
username: {
|
||||
get(): string { return this.data.username?.length > 0 ? this.data.username : '' },
|
||||
set(v:string) { this.data.username = v.length > 0 ? v : undefined },
|
||||
},
|
||||
password: {
|
||||
get(): string { return this.data.password?.length > 0 ? this.data.password : '' },
|
||||
set(v:string) { this.data.password = v.length > 0 ? v : undefined },
|
||||
},
|
||||
},
|
||||
components: { Headers }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<v-card subtitle="Hysteria">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('stats.upload')"
|
||||
hide-details
|
||||
type="number"
|
||||
:suffix="$t('stats.Mbps')"
|
||||
v-model.number="up_mbps">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('stats.download')"
|
||||
hide-details
|
||||
type="number"
|
||||
:suffix="$t('stats.Mbps')"
|
||||
min="0"
|
||||
v-model.number="down_mbps">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.hy.obfs')"
|
||||
hide-details
|
||||
v-model="data.obfs">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="direction=='out'">
|
||||
<v-text-field
|
||||
:label="$t('types.hy.auth')"
|
||||
hide-details
|
||||
v-model="data.auth_str">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="direction=='out'">
|
||||
<Network :data="data" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="data.disable_mtu_discovery" color="primary" label="Disable MTU discovery" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="data.recv_window_conn != undefined">
|
||||
<v-text-field
|
||||
label="Recv window conn"
|
||||
hide-details
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="data.recv_window_conn">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="data.recv_window != undefined">
|
||||
<v-text-field
|
||||
label="Recv window"
|
||||
hide-details
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="data.recv_window">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="data.recv_window_client != undefined">
|
||||
<v-text-field
|
||||
label="Recv window client"
|
||||
hide-details
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="data.recv_window_client">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="data.max_conn_client != undefined">
|
||||
<v-text-field
|
||||
label="Max conn client"
|
||||
hide-details
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="data.max_conn_client">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.hy.hyOptions') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionRsvConn" color="primary" label="Recv window conn" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="direction=='out'">
|
||||
<v-switch v-model="optionRsvWin" color="primary" label="Recv window" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="direction=='in'">
|
||||
<v-switch v-model="optionRsvClnt" color="primary" label="Recv window client" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="direction=='in'">
|
||||
<v-switch v-model="optionMaxConn" color="primary" label="Max conn client" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Network from '@/components/Network.vue'
|
||||
|
||||
export default {
|
||||
props: ['direction','data'],
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
optionRsvConn: {
|
||||
get(): boolean { return this.$props.data.recv_window_conn != undefined },
|
||||
set(v:boolean) { this.$props.data.recv_window_conn = v ? 15728640 : undefined }
|
||||
},
|
||||
optionRsvWin: {
|
||||
get(): boolean { return this.$props.data.recv_window != undefined },
|
||||
set(v:boolean) { this.$props.data.recv_window = v ? 67108864 : undefined }
|
||||
},
|
||||
optionRsvClnt: {
|
||||
get(): boolean { return this.$props.data.recv_window_client != undefined },
|
||||
set(v:boolean) { this.$props.data.recv_window_client = v ? 67108864 : undefined }
|
||||
},
|
||||
optionMaxConn: {
|
||||
get(): boolean { return this.$props.data.max_conn_client != undefined },
|
||||
set(v:boolean) { this.$props.data.max_conn_client = v ? 1024 : undefined }
|
||||
},
|
||||
down_mbps: {
|
||||
get() { return this.$props.data.down_mbps ? this.$props.data.down_mbps : 0 },
|
||||
set(newValue:any) {
|
||||
if (newValue.length != 0 ){
|
||||
this.$props.data.down_mbps = newValue
|
||||
this.$props.data.down = "" + newValue + " Mbps"
|
||||
} else {
|
||||
this.$props.data.down_mbps = 0
|
||||
this.$props.data.down = "0 Mbps"
|
||||
}
|
||||
}
|
||||
},
|
||||
up_mbps: {
|
||||
get() { return this.$props.data.up_mbps ? this.$props.data.up_mbps : 0 },
|
||||
set(newValue:number) { this.$props.data.up_mbps = newValue > 0 ? newValue : 0 }
|
||||
},
|
||||
},
|
||||
components: { Network }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<v-card subtitle="Hysteria2">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="direction == 'in'">
|
||||
<v-switch v-model="data.ignore_client_bandwidth" color="primary" :label="$t('types.hy.ignoreBw')" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="!data.ignore_client_bandwidth">
|
||||
<v-text-field
|
||||
:label="$t('stats.upload')"
|
||||
hide-details
|
||||
type="number"
|
||||
:suffix="$t('stats.Mbps')"
|
||||
min="0"
|
||||
v-model.number="up_mbps">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="!data.ignore_client_bandwidth">
|
||||
<v-text-field
|
||||
:label="$t('stats.download')"
|
||||
hide-details
|
||||
type="number"
|
||||
:suffix="$t('stats.Mbps')"
|
||||
min="0"
|
||||
v-model.number="down_mbps">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" v-if="data.obfs != undefined">
|
||||
<v-text-field
|
||||
:label="$t('types.hy.obfs')"
|
||||
hide-details
|
||||
v-model="data.obfs.password">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<template v-if="direction == 'in'">
|
||||
<v-card subtitle="Hysteria2 Masquerade" v-if="data.masquerade != undefined">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select v-model="masqueradeType" hide-details :label="$t('type')" :items="masqTypes"></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="8" v-if="masqueradeType == ''">
|
||||
<v-text-field
|
||||
label="HTTP3 server on auth fails"
|
||||
placeholder="file:///var/www | http://127.0.0.1:8080"
|
||||
v-model="data.masquerade"
|
||||
hide-details>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="8" v-if="masqueradeType == 'file'">
|
||||
<v-text-field
|
||||
label="File server root directory"
|
||||
placeholder="/var/www"
|
||||
v-model="data.masquerade.directory"
|
||||
hide-details>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="masqueradeType == 'string'">
|
||||
<v-text-field
|
||||
label="HTTP Code"
|
||||
type="number"
|
||||
min="100"
|
||||
max="599"
|
||||
v-model.number="data.masquerade.status_code"
|
||||
hide-details>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="masqueradeType == 'proxy'">
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
label="Target URL"
|
||||
placeholder="http://example.com:8080"
|
||||
v-model="data.masquerade.url"
|
||||
hide-details>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch
|
||||
label="Rewrite Host"
|
||||
v-model="data.masquerade.rewrite_host"
|
||||
color="primary"
|
||||
hide-details>
|
||||
</v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<template v-if="masqueradeType == 'string'">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="8">
|
||||
<v-text-field
|
||||
label="Content"
|
||||
v-model="data.masquerade.content"
|
||||
hide-details>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<Headers :data="data.masquerade" />
|
||||
</template>
|
||||
</v-card>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.pw')"
|
||||
hide-details
|
||||
v-model="data.password">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<Network :data="data" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="8" v-if="optionMPort">
|
||||
<v-text-field
|
||||
:label="$t('rule.portRange') + ' ' + $t('commaSeparated')"
|
||||
v-model="server_ports">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="optionMPort">
|
||||
<v-text-field
|
||||
:label="$t('ruleset.interval')"
|
||||
type="number"
|
||||
min="0"
|
||||
:suffix="$t('date.s')"
|
||||
v-model.number="hop_interval">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu v-model="menu" :close-on-content-click="false" location="start">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('types.hy.hy2Options') }}</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionObfs" color="primary" :label="$t('types.hy.obfs')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<template v-if="direction == 'in'">
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionMasq" color="primary" label="Masquerade" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-list-item>
|
||||
<v-switch v-model="optionMPort" color="primary" :label="$t('rule.portRange')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Network from '@/components/Network.vue'
|
||||
import Headers from '@/components/Headers.vue'
|
||||
import { i18n } from '@/locales'
|
||||
|
||||
export default {
|
||||
props: ['direction', 'data'],
|
||||
data() {
|
||||
return {
|
||||
menu: false,
|
||||
masqTypes: [
|
||||
{ title: i18n.global.t('rule.simple'), value: '' },
|
||||
{ title: "File server", value: "file" },
|
||||
{ title: "Reverse Proxy", value: "proxy" },
|
||||
{ title: "Fixed response", value: "string" },
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
down_mbps: {
|
||||
get() { return this.$props.data.down_mbps?? 0 },
|
||||
set(v:number) { this.$props.data.down_mbps = v>0 ? v : undefined }
|
||||
},
|
||||
up_mbps: {
|
||||
get() { return this.$props.data.up_mbps?? 0 },
|
||||
set(v:number) { this.$props.data.up_mbps = v>0 ? v : undefined }
|
||||
},
|
||||
server_ports: {
|
||||
get() { return this.$props.data.server_ports?.join(',')?? [] },
|
||||
set(v:string) { this.$props.data.server_ports = v.length > 0 ? v.split(',') : undefined }
|
||||
},
|
||||
masqueradeType: {
|
||||
get() { return typeof this.$props.data.masquerade === 'object' ? this.$props.data.masquerade.type?? '' : '' },
|
||||
set(v:string) {
|
||||
if (v == '') {
|
||||
this.$props.data.masquerade = ''
|
||||
} else {
|
||||
this.$props.data.masquerade = { type: v }
|
||||
}
|
||||
}
|
||||
},
|
||||
hop_interval: {
|
||||
get() { return this.$props.data.hop_interval? parseInt(this.$props.data.hop_interval.replace('s','')) : 0 },
|
||||
set(v:number) { this.$props.data.hop_interval = v>0 ? v + 's' : undefined }
|
||||
},
|
||||
optionObfs: {
|
||||
get(): boolean { return this.$props.data.obfs != undefined },
|
||||
set(v:boolean) { this.$props.data.obfs = v ? { type: "salamander", password: "" } : undefined }
|
||||
},
|
||||
optionMasq: {
|
||||
get(): boolean { return this.$props.data.masquerade != undefined },
|
||||
set(v:boolean) { this.$props.data.masquerade = v ? "" : undefined }
|
||||
},
|
||||
optionMPort: {
|
||||
get(): boolean { return this.$props.data.server_ports != undefined },
|
||||
set(v:boolean) { this.$props.data.server_ports = v ? [] : undefined }
|
||||
}
|
||||
},
|
||||
components: { Network, Headers }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-subtitle v-if="direction != 'out_json'">Naive</v-card-subtitle>
|
||||
<!-- Inbound -->
|
||||
<template v-if="direction === 'in'">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<Network :data="data" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('types.naive.quicCongestion')"
|
||||
:items="inbCngs"
|
||||
v-model="data.quic_congestion_control"
|
||||
@click:clear="delete data.quic_congestion_control"
|
||||
clearable>
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<!-- Outbound -->
|
||||
<template v-if="['out', 'out_json'].includes(direction)">
|
||||
<v-row v-if="direction === 'out'">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.un')"
|
||||
hide-details
|
||||
v-model="data.username">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.pw')"
|
||||
hide-details
|
||||
type="password"
|
||||
v-model="data.password">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-text-field
|
||||
:label="$t('types.naive.insecureConcurrency')"
|
||||
type="number"
|
||||
min="0"
|
||||
hide-details
|
||||
v-model.number="insecure_concurrency">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="udpOverTcp" color="primary" :label="$t('types.naive.udpOverTcp')" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="direction === 'out'">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-switch v-model="data.quic" color="primary" :label="$t('types.naive.quic')" hide-details></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="data.quic">
|
||||
<v-select
|
||||
hide-details
|
||||
:label="$t('types.naive.quicCongestion')"
|
||||
:items="outCngs"
|
||||
@click:clear="delete data.quic_congestion_control"
|
||||
clearable
|
||||
v-model="data.quic_congestion_control">
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<Headers :data="extra_headers" />
|
||||
</template>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Network from '@/components/Network.vue'
|
||||
import Headers from '@/components/Headers.vue'
|
||||
|
||||
export default {
|
||||
props: ['data', 'direction'],
|
||||
data() {
|
||||
return {
|
||||
inbCngs: [
|
||||
{ title: 'BBR', value: 'bbr'},
|
||||
{ title: 'BBR Standard', value: 'bbr_standard'},
|
||||
{ title: 'BBRv2', value: 'bbr2'},
|
||||
{ title: 'BBRv2 variant', value: 'bbr2_variant'},
|
||||
{ title: 'Cubic', value: 'cubic'},
|
||||
{ title: 'New Reno', value: 'reno'},
|
||||
],
|
||||
outCngs: [
|
||||
{ title: 'BBR', value: 'bbr'},
|
||||
{ title: 'BBR2', value: 'bbr2'},
|
||||
{ title: 'Cubic', value: 'cubic'},
|
||||
{ title: 'Reno', value: 'reno'},
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
udpOverTcp: {
|
||||
get(): boolean {
|
||||
const d = this.$props.data
|
||||
return d?.udp_over_tcp === true || (d?.udp_over_tcp && (d.udp_over_tcp as any)?.enabled)
|
||||
},
|
||||
set(v: boolean) {
|
||||
if (v) {
|
||||
this.$props.data.udp_over_tcp = { enabled: true }
|
||||
} else {
|
||||
this.$props.data.udp_over_tcp = false
|
||||
}
|
||||
}
|
||||
},
|
||||
insecure_concurrency: {
|
||||
get(): number { return this.$props.data?.insecure_concurrency ?? 0 },
|
||||
set(v: number) {
|
||||
this.$props.data.insecure_concurrency = v > 0 ? v : undefined
|
||||
}
|
||||
},
|
||||
extra_headers(): any {
|
||||
const d = this.$props.data
|
||||
return new Proxy({}, {
|
||||
get(_, prop) {
|
||||
if (prop === 'headers') return d?.extra_headers ?? {}
|
||||
return undefined
|
||||
},
|
||||
set(_, prop, value) {
|
||||
if (prop === 'headers') {
|
||||
d.extra_headers = value
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
components: { Network, Headers }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<v-card subtitle="ShadowTls">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select
|
||||
hide-details
|
||||
:items="[1,2,3]"
|
||||
:label="$t('version')"
|
||||
v-model="version">
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" v-if="data.version > 1">
|
||||
<v-text-field
|
||||
:label="$t('types.pw')"
|
||||
hide-details
|
||||
v-model="data.password">
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
export default {
|
||||
props: ['data'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
version: {
|
||||
get() { return this.$props.data.version ?? 3 },
|
||||
set(v: number) {
|
||||
this.$props.data.version = v
|
||||
if (v==1) {
|
||||
delete this.$props.data.password
|
||||
} else if (this.$props.data.password === undefined ) {
|
||||
this.$props.data.password = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<v-card subtitle="Selector">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-combobox
|
||||
v-model="data.outbounds"
|
||||
:items="tags"
|
||||
:label="$t('pages.outbounds')"
|
||||
multiple
|
||||
@update:model-value="updateDefault"
|
||||
chips
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-combobox
|
||||
v-model="data.default"
|
||||
:items="data.outbounds"
|
||||
:label="$t('types.lb.defaultOut')"
|
||||
clearable
|
||||
hide-details
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-switch v-model="data.interrupt_exist_connections" color="primary" :label="$t('types.lb.interruptConn')" hide-details></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
export default {
|
||||
props: ['data','tags'],
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
updateDefault() {
|
||||
if (!this.$props.data.outbounds?.includes(this.$props.data.default)) {
|
||||
delete this.$props.data.default
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user