├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question-template.md ├── dependabot.yml └── workflows │ ├── docker-core.yml │ ├── docker.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── backend ├── .gitignore ├── api │ ├── api.go │ ├── session.go │ └── utils.go ├── app │ └── app.go ├── cmd │ ├── admin.go │ ├── cmd.go │ ├── migration.go │ └── setting.go ├── config │ ├── config.go │ ├── config.json │ ├── name │ └── version ├── cronjob │ ├── cronJob.go │ ├── delStatsJob.go │ ├── depleteJob.go │ └── statsJob.go ├── database │ ├── db.go │ └── model │ │ └── model.go ├── go.mod ├── go.sum ├── logger │ └── logger.go ├── main.go ├── middleware │ └── domainValidator.go ├── network │ ├── auto_https_conn.go │ └── auto_https_listener.go ├── service │ ├── client.go │ ├── config.go │ ├── inData.go │ ├── panel.go │ ├── server.go │ ├── setting.go │ ├── sinxbox.go │ ├── stats.go │ ├── tls.go │ └── user.go ├── singbox │ ├── controller.go │ └── v2rayApi.go ├── sub │ ├── jsonService.go │ ├── linkService.go │ ├── sub.go │ ├── subHandler.go │ └── subService.go ├── util │ ├── base64.go │ ├── common │ │ ├── err.go │ │ └── random.go │ └── linkToJson.go └── web │ └── web.go ├── build.sh ├── core ├── Dockerfile └── runSingbox.sh ├── docker-compose.yml ├── frontend ├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public │ └── assets │ │ └── favicon.ico ├── src │ ├── App.vue │ ├── assets │ │ ├── Vazirmatn-UI-NL-Regular.woff2 │ │ ├── logo.png │ │ └── logo.svg │ ├── components │ │ ├── Addr.vue │ │ ├── DateTime.vue │ │ ├── Dial.vue │ │ ├── Headers.vue │ │ ├── Listen.vue │ │ ├── Main.vue │ │ ├── Multiplex.vue │ │ ├── Network.vue │ │ ├── OutJson.vue │ │ ├── Rule.vue │ │ ├── SubJsonExt.vue │ │ ├── Transport.vue │ │ ├── UoT.vue │ │ ├── Users.vue │ │ ├── WgPeer.vue │ │ ├── message.vue │ │ ├── protocols │ │ │ ├── Direct.vue │ │ │ ├── Http.vue │ │ │ ├── Hysteria.vue │ │ │ ├── Hysteria2.vue │ │ │ ├── Naive.vue │ │ │ ├── OutShadowTls.vue │ │ │ ├── Selector.vue │ │ │ ├── ShadowTls.vue │ │ │ ├── Shadowsocks.vue │ │ │ ├── Socks.vue │ │ │ ├── Ssh.vue │ │ │ ├── TProxy.vue │ │ │ ├── Tor.vue │ │ │ ├── Trojan.vue │ │ │ ├── Tuic.vue │ │ │ ├── UrlTest.vue │ │ │ ├── Vless.vue │ │ │ ├── Vmess.vue │ │ │ └── Wireguard.vue │ │ ├── tiles │ │ │ ├── Gauge.vue │ │ │ └── History.vue │ │ ├── tls │ │ │ ├── Acme.vue │ │ │ ├── Ech.vue │ │ │ ├── InTLS.vue │ │ │ └── OutTLS.vue │ │ └── transports │ │ │ ├── Http.vue │ │ │ ├── HttpUpgrade.vue │ │ │ ├── WebSocket.vue │ │ │ └── gRPC.vue │ ├── layouts │ │ ├── default │ │ │ ├── AppBar.vue │ │ │ ├── Default.vue │ │ │ ├── Drawer.vue │ │ │ └── View.vue │ │ └── modals │ │ │ ├── Admin.vue │ │ │ ├── Changes.vue │ │ │ ├── Client.vue │ │ │ ├── Inbound.vue │ │ │ ├── Logs.vue │ │ │ ├── Outbound.vue │ │ │ ├── QrCode.vue │ │ │ ├── Rule.vue │ │ │ ├── Ruleset.vue │ │ │ ├── Stats.vue │ │ │ └── Tls.vue │ ├── locales │ │ ├── en.ts │ │ ├── fa.ts │ │ ├── index.ts │ │ ├── vi.ts │ │ ├── zhcn.ts │ │ └── zhtw.ts │ ├── main.ts │ ├── plugins │ │ ├── api.ts │ │ ├── httputil.ts │ │ ├── inData.ts │ │ ├── index.ts │ │ ├── link.ts │ │ ├── outJson.ts │ │ ├── randomUtil.ts │ │ ├── utils.ts │ │ └── vuetify.ts │ ├── router │ │ └── index.ts │ ├── store │ │ ├── index.ts │ │ └── modules │ │ │ └── data.ts │ ├── styles │ │ └── settings.scss │ ├── types │ │ ├── brutal.ts │ │ ├── clients.ts │ │ ├── config.ts │ │ ├── dial.ts │ │ ├── inTls.ts │ │ ├── inbounds.ts │ │ ├── multiplex.ts │ │ ├── outTls.ts │ │ ├── outbounds.ts │ │ ├── rules.ts │ │ └── transport.ts │ ├── views │ │ ├── Admins.vue │ │ ├── Basics.vue │ │ ├── Clients.vue │ │ ├── Home.vue │ │ ├── Inbounds.vue │ │ ├── Login.vue │ │ ├── Outbounds.vue │ │ ├── Rules.vue │ │ ├── Settings.vue │ │ └── Tls.vue │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.mts ├── install.sh ├── runSUI.sh ├── s-ui.service ├── s-ui.sh └── sing-box.service /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: alireza0 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question template 3 | about: Ask if it is not clear that it is a bug 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "gomod" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/docker-core.yml: -------------------------------------------------------------------------------- 1 | name: Sing-box Docker Image CI 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Get latest release 13 | id: get_release 14 | run: | 15 | latest_release=$(curl -Ls "https://api.github.com/repos/sagernet/sing-box/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') 16 | echo "latest_release: $latest_release" 17 | echo "latest_release=$latest_release" >> $GITHUB_OUTPUT 18 | 19 | - name: Docker meta 20 | id: meta 21 | uses: docker/metadata-action@v5 22 | with: 23 | images: | 24 | alireza7/s-ui-singbox 25 | ghcr.io/alireza0/s-ui-singbox 26 | tags: | 27 | type=sha 28 | type=pep440,pattern=${{ steps.get_release.outputs.latest_release }} 29 | 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v3 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | 36 | - name: Login to Docker Hub 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 40 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 41 | 42 | - name: Login to GHCR 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ghcr.io 46 | username: ${{ github.repository_owner }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Build and push 50 | uses: docker/build-push-action@v6 51 | with: 52 | context: core/ 53 | push: true 54 | build-args: SINGBOX_VER=${{ steps.get_release.outputs.latest_release }} 55 | platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/386 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Docker meta 16 | id: meta 17 | uses: docker/metadata-action@v5 18 | with: 19 | images: | 20 | alireza7/s-ui 21 | ghcr.io/alireza0/s-ui 22 | tags: | 23 | type=ref,event=branch 24 | type=ref,event=tag 25 | type=pep440,pattern={{version}} 26 | 27 | - name: Set up QEMU 28 | uses: docker/setup-qemu-action@v3 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | 33 | - name: Login to Docker Hub 34 | uses: docker/login-action@v3 35 | with: 36 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 37 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 38 | 39 | - name: Login to GHCR 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.repository_owner }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Build and push 47 | uses: docker/build-push-action@v6 48 | with: 49 | context: . 50 | push: true 51 | platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release S-UI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | platform: 14 | - amd64 15 | - arm64 16 | - armv7 17 | - armv6 18 | - armv5 19 | - 386 20 | - s390x 21 | runs-on: ubuntu-20.04 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4.1.1 25 | 26 | - name: Setup Go 27 | uses: actions/setup-go@v5 28 | with: 29 | cache: false 30 | go-version: '1.22' 31 | 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: '20' 36 | registry-url: 'https://registry.npmjs.org' 37 | 38 | - name: Install dependencies 39 | run: | 40 | sudo apt-get update 41 | if [ "${{ matrix.platform }}" == "arm64" ]; then 42 | sudo apt install gcc-aarch64-linux-gnu 43 | elif [ "${{ matrix.platform }}" == "armv7" ]; then 44 | sudo apt install gcc-arm-linux-gnueabihf 45 | elif [ "${{ matrix.platform }}" == "armv6" ]; then 46 | sudo apt install gcc-arm-linux-gnueabihf 47 | elif [ "${{ matrix.platform }}" == "armv5" ]; then 48 | sudo apt install gcc-arm-linux-gnueabi 49 | elif [ "${{ matrix.platform }}" == "386" ]; then 50 | sudo apt install gcc-i686-linux-gnu 51 | elif [ "${{ matrix.platform }}" == "s390x" ]; then 52 | sudo apt install gcc-s390x-linux-gnu 53 | fi 54 | 55 | - name: Build frontend 56 | run: | 57 | cd frontend 58 | npm install 59 | npm run build 60 | cd .. 61 | mv frontend/dist backend/web/html 62 | 63 | - name: Build s-ui & singbox 64 | run: | 65 | export CGO_ENABLED=1 66 | export GOOS=linux 67 | export GOARCH=${{ matrix.platform }} 68 | if [ "${{ matrix.platform }}" == "arm64" ]; then 69 | export GOARCH=arm64 70 | export CC=aarch64-linux-gnu-gcc 71 | elif [ "${{ matrix.platform }}" == "armv7" ]; then 72 | export GOARCH=arm 73 | export GOARM=7 74 | export CC=arm-linux-gnueabihf-gcc 75 | elif [ "${{ matrix.platform }}" == "armv6" ]; then 76 | export GOARCH=arm 77 | export GOARM=6 78 | export CC=arm-linux-gnueabihf-gcc 79 | elif [ "${{ matrix.platform }}" == "armv5" ]; then 80 | export GOARCH=arm 81 | export GOARM=5 82 | export CC=arm-linux-gnueabi-gcc 83 | elif [ "${{ matrix.platform }}" == "386" ]; then 84 | export GOARCH=386 85 | export CC=i686-linux-gnu-gcc 86 | elif [ "${{ matrix.platform }}" == "s390x" ]; then 87 | export GOARCH=s390x 88 | export CC=s390x-linux-gnu-gcc 89 | fi 90 | 91 | #### Build Sing-Box 92 | export VERSION=v1.9.3 93 | git clone -b $VERSION https://github.com/SagerNet/sing-box 94 | cd sing-box 95 | go build -tags with_quic,with_grpc,with_wireguard,with_ech,with_utls,with_reality_server,with_acme,with_v2ray_api,with_clash_api,with_gvisor \ 96 | -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${VERSION}' -s -w -buildid=" \ 97 | -o sing-box ./cmd/sing-box 98 | cd .. 99 | 100 | ### Build s-ui 101 | cd backend 102 | go build -o ../sui main.go 103 | cd .. 104 | 105 | mkdir s-ui 106 | cp sui s-ui/ 107 | cp s-ui.service s-ui/ 108 | cp sing-box.service s-ui/ 109 | mkdir s-ui/bin 110 | cp sing-box/sing-box s-ui/bin/ 111 | cp core/runSingbox.sh s-ui/bin/ 112 | 113 | - name: Package 114 | run: tar -zcvf s-ui-linux-${{ matrix.platform }}.tar.gz s-ui 115 | 116 | - name: Upload 117 | uses: svenstaro/upload-release-action@v2 118 | with: 119 | repo_token: ${{ secrets.GITHUB_TOKEN }} 120 | tag: ${{ github.ref }} 121 | file: s-ui-linux-${{ matrix.platform }}.tar.gz 122 | asset_name: s-ui-linux-${{ matrix.platform }}.tar.gz 123 | prerelease: true 124 | overwrite: true 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | release/ 4 | backup/ 5 | bin/ 6 | db/ 7 | sui 8 | main 9 | tmp 10 | .sync* 11 | *.tar.gz 12 | 13 | # local env files 14 | .env.local 15 | .env.*.local 16 | 17 | # Log files 18 | *.log* 19 | .cache 20 | 21 | # Editor directories and files 22 | .idea 23 | .vscode 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM node:alpine as front-builder 2 | WORKDIR /app 3 | COPY frontend/ ./ 4 | RUN npm install && npm run build 5 | 6 | FROM golang:1.22-alpine AS backend-builder 7 | WORKDIR /app 8 | ARG TARGETARCH 9 | ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE" 10 | ENV CGO_ENABLED=1 11 | ENV GOARCH=$TARGETARCH 12 | RUN apk update && apk --no-cache --update add build-base gcc wget unzip 13 | COPY backend/ ./ 14 | COPY --from=front-builder /app/dist/ /app/web/html/ 15 | RUN go build -ldflags="-w -s" -o sui main.go 16 | 17 | FROM --platform=$TARGETPLATFORM alpine 18 | LABEL org.opencontainers.image.authors="alireza7@gmail.com" 19 | ENV TZ=Asia/Tehran 20 | WORKDIR /app 21 | RUN apk add --no-cache --update ca-certificates tzdata 22 | COPY --from=backend-builder /app/sui /app/ 23 | VOLUME [ "s-ui" ] 24 | CMD [ "./sui" ] -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | release/ 4 | backup/ 5 | bin/ 6 | sui 7 | web/html 8 | main 9 | tmp 10 | .sync* 11 | *.tar.gz 12 | 13 | # local env files 14 | .env.local 15 | .env.*.local 16 | 17 | # Log files 18 | *.log* 19 | .cache 20 | 21 | # Editor directories and files 22 | .idea 23 | .vscode 24 | -------------------------------------------------------------------------------- /backend/api/session.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/gob" 5 | "s-ui/database/model" 6 | 7 | sessions "github.com/Calidity/gin-sessions" 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | const ( 12 | loginUser = "LOGIN_USER" 13 | ) 14 | 15 | func init() { 16 | gob.Register(model.User{}) 17 | } 18 | 19 | func SetLoginUser(c *gin.Context, userName string) error { 20 | s := sessions.Default(c) 21 | s.Set(loginUser, userName) 22 | return s.Save() 23 | } 24 | 25 | func SetMaxAge(c *gin.Context, maxAge int) error { 26 | s := sessions.Default(c) 27 | s.Options(sessions.Options{ 28 | Path: "/", 29 | MaxAge: maxAge, 30 | }) 31 | return s.Save() 32 | } 33 | 34 | func GetLoginUser(c *gin.Context) string { 35 | s := sessions.Default(c) 36 | obj := s.Get(loginUser) 37 | if obj == nil { 38 | return "" 39 | } 40 | objStr, ok := obj.(string) 41 | if !ok { 42 | return "" 43 | } 44 | return objStr 45 | } 46 | 47 | func IsLogin(c *gin.Context) bool { 48 | return GetLoginUser(c) != "" 49 | } 50 | 51 | func ClearSession(c *gin.Context) { 52 | s := sessions.Default(c) 53 | s.Clear() 54 | s.Options(sessions.Options{ 55 | Path: "/", 56 | MaxAge: -1, 57 | }) 58 | s.Save() 59 | } 60 | -------------------------------------------------------------------------------- /backend/api/utils.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "s-ui/logger" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type Msg struct { 13 | Success bool `json:"success"` 14 | Msg string `json:"msg"` 15 | Obj interface{} `json:"obj"` 16 | } 17 | 18 | func getRemoteIp(c *gin.Context) string { 19 | value := c.GetHeader("X-Forwarded-For") 20 | if value != "" { 21 | ips := strings.Split(value, ",") 22 | return ips[0] 23 | } else { 24 | addr := c.Request.RemoteAddr 25 | ip, _, _ := net.SplitHostPort(addr) 26 | return ip 27 | } 28 | } 29 | 30 | func jsonMsg(c *gin.Context, msg string, err error) { 31 | jsonMsgObj(c, msg, nil, err) 32 | } 33 | 34 | func jsonObj(c *gin.Context, obj interface{}, err error) { 35 | jsonMsgObj(c, "", obj, err) 36 | } 37 | 38 | func jsonMsgObj(c *gin.Context, msg string, obj interface{}, err error) { 39 | m := Msg{ 40 | Obj: obj, 41 | } 42 | if err == nil { 43 | m.Success = true 44 | if msg != "" { 45 | m.Msg = msg 46 | } 47 | } else { 48 | m.Success = false 49 | m.Msg = msg + err.Error() 50 | logger.Warning("failed :", err) 51 | } 52 | c.JSON(http.StatusOK, m) 53 | } 54 | 55 | func pureJsonMsg(c *gin.Context, success bool, msg string) { 56 | if success { 57 | c.JSON(http.StatusOK, Msg{ 58 | Success: true, 59 | Msg: msg, 60 | }) 61 | } else { 62 | c.JSON(http.StatusOK, Msg{ 63 | Success: false, 64 | Msg: msg, 65 | }) 66 | } 67 | } 68 | 69 | func checkLogin(c *gin.Context) { 70 | if !IsLogin(c) { 71 | if c.GetHeader("X-Requested-With") == "XMLHttpRequest" { 72 | pureJsonMsg(c, false, "Invalid login") 73 | } else { 74 | c.Redirect(http.StatusTemporaryRedirect, "/login") 75 | } 76 | c.Abort() 77 | } else { 78 | c.Next() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /backend/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "s-ui/config" 6 | "s-ui/cronjob" 7 | "s-ui/database" 8 | "s-ui/logger" 9 | "s-ui/service" 10 | "s-ui/sub" 11 | "s-ui/web" 12 | 13 | "github.com/op/go-logging" 14 | ) 15 | 16 | type APP struct { 17 | service.SettingService 18 | webServer *web.Server 19 | subServer *sub.Server 20 | cronJob *cronjob.CronJob 21 | } 22 | 23 | func NewApp() *APP { 24 | return &APP{} 25 | } 26 | 27 | func (a *APP) Init() error { 28 | log.Printf("%v %v", config.GetName(), config.GetVersion()) 29 | 30 | a.initLog() 31 | 32 | err := database.InitDB(config.GetDBPath()) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | a.cronJob = cronjob.NewCronJob() 38 | a.webServer = web.NewServer() 39 | a.subServer = sub.NewServer() 40 | 41 | configService := service.NewConfigService() 42 | err = configService.InitConfig() 43 | if err != nil { 44 | return err 45 | } 46 | return nil 47 | } 48 | 49 | func (a *APP) Start() error { 50 | loc, err := a.SettingService.GetTimeLocation() 51 | if err != nil { 52 | return err 53 | } 54 | 55 | trafficAge, err := a.SettingService.GetTrafficAge() 56 | if err != nil { 57 | return err 58 | } 59 | 60 | err = a.cronJob.Start(loc, trafficAge) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | err = a.webServer.Start() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | err = a.subServer.Start() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (a *APP) Stop() { 79 | a.cronJob.Stop() 80 | err := a.subServer.Stop() 81 | if err != nil { 82 | logger.Warning("stop Sub Server err:", err) 83 | } 84 | err = a.webServer.Stop() 85 | if err != nil { 86 | logger.Warning("stop Web Server err:", err) 87 | } 88 | } 89 | 90 | func (a *APP) initLog() { 91 | switch config.GetLogLevel() { 92 | case config.Debug: 93 | logger.InitLogger(logging.DEBUG) 94 | case config.Info: 95 | logger.InitLogger(logging.INFO) 96 | case config.Warn: 97 | logger.InitLogger(logging.WARNING) 98 | case config.Error: 99 | logger.InitLogger(logging.ERROR) 100 | default: 101 | log.Fatal("unknown log level:", config.GetLogLevel()) 102 | } 103 | } 104 | 105 | func (a *APP) RestartApp() { 106 | a.Stop() 107 | a.Start() 108 | } 109 | -------------------------------------------------------------------------------- /backend/cmd/admin.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "s-ui/config" 6 | "s-ui/database" 7 | "s-ui/service" 8 | ) 9 | 10 | func resetAdmin() { 11 | err := database.InitDB(config.GetDBPath()) 12 | if err != nil { 13 | fmt.Println(err) 14 | return 15 | } 16 | 17 | userService := service.UserService{} 18 | err = userService.UpdateFirstUser("admin", "admin") 19 | if err != nil { 20 | fmt.Println("reset admin credentials failed:", err) 21 | } else { 22 | fmt.Println("reset admin credentials success") 23 | } 24 | } 25 | 26 | func updateAdmin(username string, password string) { 27 | err := database.InitDB(config.GetDBPath()) 28 | if err != nil { 29 | fmt.Println(err) 30 | return 31 | } 32 | 33 | if username != "" || password != "" { 34 | userService := service.UserService{} 35 | err := userService.UpdateFirstUser(username, password) 36 | if err != nil { 37 | fmt.Println("reset admin credentials failed:", err) 38 | } else { 39 | fmt.Println("reset admin credentials success") 40 | } 41 | } 42 | } 43 | 44 | func showAdmin() { 45 | err := database.InitDB(config.GetDBPath()) 46 | if err != nil { 47 | fmt.Println(err) 48 | return 49 | } 50 | userService := service.UserService{} 51 | userModel, err := userService.GetFirstUser() 52 | if err != nil { 53 | fmt.Println("get current user info failed,error info:", err) 54 | } 55 | username := userModel.Username 56 | userpasswd := userModel.Password 57 | if (username == "") || (userpasswd == "") { 58 | fmt.Println("current username or password is empty") 59 | } 60 | fmt.Println("First admin credentials:") 61 | fmt.Println("\tUsername:\t", username) 62 | fmt.Println("\tPassword:\t", userpasswd) 63 | } 64 | -------------------------------------------------------------------------------- /backend/cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "s-ui/config" 8 | ) 9 | 10 | func ParseCmd() { 11 | var showVersion bool 12 | flag.BoolVar(&showVersion, "v", false, "show version") 13 | 14 | adminCmd := flag.NewFlagSet("admin", flag.ExitOnError) 15 | settingCmd := flag.NewFlagSet("setting", flag.ExitOnError) 16 | 17 | var username string 18 | var password string 19 | var port int 20 | var path string 21 | var subPort int 22 | var subPath string 23 | var reset bool 24 | var show bool 25 | settingCmd.BoolVar(&reset, "reset", false, "reset all settings") 26 | settingCmd.BoolVar(&show, "show", false, "show current settings") 27 | settingCmd.IntVar(&port, "port", 0, "set panel port") 28 | settingCmd.StringVar(&path, "path", "", "set panel path") 29 | settingCmd.IntVar(&subPort, "subPort", 0, "set sub port") 30 | settingCmd.StringVar(&subPath, "subPath", "", "set sub path") 31 | 32 | adminCmd.BoolVar(&show, "show", false, "show first admin credentials") 33 | adminCmd.BoolVar(&reset, "reset", false, "reset first admin credentials") 34 | adminCmd.StringVar(&username, "username", "", "set login username") 35 | adminCmd.StringVar(&password, "password", "", "set login password") 36 | 37 | oldUsage := flag.Usage 38 | flag.Usage = func() { 39 | oldUsage() 40 | fmt.Println() 41 | fmt.Println("Commands:") 42 | fmt.Println(" admin set/reset/show first admin credentials") 43 | fmt.Println(" migrate migrate form older version") 44 | fmt.Println(" setting set/reset/show settings") 45 | fmt.Println() 46 | adminCmd.Usage() 47 | fmt.Println() 48 | settingCmd.Usage() 49 | } 50 | 51 | flag.Parse() 52 | if showVersion { 53 | fmt.Println(config.GetVersion()) 54 | return 55 | } 56 | 57 | switch os.Args[1] { 58 | case "admin": 59 | err := adminCmd.Parse(os.Args[2:]) 60 | if err != nil { 61 | fmt.Println(err) 62 | return 63 | } 64 | switch { 65 | case show: 66 | showAdmin() 67 | case reset: 68 | resetAdmin() 69 | default: 70 | updateAdmin(username, password) 71 | showAdmin() 72 | } 73 | 74 | case "migrate": 75 | migrateDb() 76 | 77 | case "setting": 78 | err := settingCmd.Parse(os.Args[2:]) 79 | if err != nil { 80 | fmt.Println(err) 81 | return 82 | } 83 | switch { 84 | case show: 85 | showSetting() 86 | case reset: 87 | resetSetting() 88 | default: 89 | updateSetting(port, path, subPort, subPath) 90 | showSetting() 91 | } 92 | default: 93 | fmt.Println("Invalid subcommands") 94 | flag.Usage() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /backend/cmd/migration.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "s-ui/config" 9 | "s-ui/database" 10 | "s-ui/database/model" 11 | "strings" 12 | 13 | "gorm.io/gorm" 14 | ) 15 | 16 | func migrateDb() { 17 | // void running on first install 18 | path := config.GetDBPath() 19 | _, err := os.Stat(path) 20 | if err != nil { 21 | return 22 | } 23 | 24 | err = database.OpenDB(path) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | db := database.GetDB() 29 | tx := db.Begin() 30 | defer func() { 31 | if err == nil { 32 | tx.Commit() 33 | } else { 34 | tx.Rollback() 35 | } 36 | }() 37 | fmt.Println("Start migrating database...") 38 | err = migrateClientSchema(tx) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | err = changesObj(tx) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | fmt.Println("Migration done!") 47 | } 48 | 49 | func migrateClientSchema(db *gorm.DB) error { 50 | rows, err := db.Raw("PRAGMA table_info(clients)").Rows() 51 | if err != nil { 52 | fmt.Println(err) 53 | return err 54 | } 55 | defer rows.Close() 56 | 57 | for rows.Next() { 58 | var ( 59 | cid int 60 | cname string 61 | ctype string 62 | notnull int 63 | dfltValue interface{} 64 | pk int 65 | ) 66 | 67 | rows.Scan(&cid, &cname, &ctype, ¬null, &dfltValue, &pk) 68 | if cname == "config" || cname == "inbounds" || cname == "links" { 69 | if ctype == "text" { 70 | fmt.Printf("Column %s has type TEXT\n", cname) 71 | oldData := make([]struct { 72 | Id uint 73 | Data string 74 | }, 0) 75 | db.Model(model.Client{}).Select("id", cname+" as data").Scan(&oldData) 76 | for _, data := range oldData { 77 | var newData []byte 78 | switch cname { 79 | case "inbounds": 80 | inbounds := strings.Split(data.Data, ",") 81 | newData, _ = json.MarshalIndent(inbounds, " ", " ") 82 | case "config": 83 | jsonData := map[string]interface{}{} 84 | json.Unmarshal([]byte(data.Data), &jsonData) 85 | newData, _ = json.MarshalIndent(jsonData, " ", " ") 86 | case "links": 87 | jsonData := make([]interface{}, 0) 88 | json.Unmarshal([]byte(data.Data), &jsonData) 89 | newData, _ = json.MarshalIndent(jsonData, " ", " ") 90 | } 91 | err = db.Model(model.Client{}).Where("id = ?", data.Id).UpdateColumn(cname, newData).Error 92 | if err != nil { 93 | return err 94 | } 95 | } 96 | } 97 | } 98 | } 99 | db.AutoMigrate(model.Client{}) 100 | return nil 101 | } 102 | 103 | func changesObj(db *gorm.DB) error { 104 | return db.Exec("UPDATE changes SET obj = CAST('\"' || CAST(obj AS TEXT) || '\"' AS BLOB) WHERE actor = ? and obj not like ?", "DepleteJob", "\"%\"").Error 105 | } 106 | -------------------------------------------------------------------------------- /backend/cmd/setting.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "s-ui/config" 6 | "s-ui/database" 7 | "s-ui/service" 8 | ) 9 | 10 | func resetSetting() { 11 | err := database.InitDB(config.GetDBPath()) 12 | if err != nil { 13 | fmt.Println(err) 14 | return 15 | } 16 | 17 | settingService := service.SettingService{} 18 | err = settingService.ResetSettings() 19 | if err != nil { 20 | fmt.Println("reset setting failed:", err) 21 | } else { 22 | fmt.Println("reset setting success") 23 | } 24 | } 25 | 26 | func updateSetting(port int, path string, subPort int, subPath string) { 27 | err := database.InitDB(config.GetDBPath()) 28 | if err != nil { 29 | fmt.Println(err) 30 | return 31 | } 32 | 33 | settingService := service.SettingService{} 34 | 35 | if port > 0 { 36 | err := settingService.SetPort(port) 37 | if err != nil { 38 | fmt.Println("set port failed:", err) 39 | } else { 40 | fmt.Println("set port success") 41 | } 42 | } 43 | if path != "" { 44 | err := settingService.SetWebPath(path) 45 | if err != nil { 46 | fmt.Println("set path failed:", err) 47 | } else { 48 | fmt.Println("set path success") 49 | } 50 | } 51 | if subPort > 0 { 52 | err := settingService.SetSubPort(subPort) 53 | if err != nil { 54 | fmt.Println("set sub port failed:", err) 55 | } else { 56 | fmt.Println("set sub port success") 57 | } 58 | } 59 | if subPath != "" { 60 | err := settingService.SetSubPath(subPath) 61 | if err != nil { 62 | fmt.Println("set sub path failed:", err) 63 | } else { 64 | fmt.Println("set sub path success") 65 | } 66 | } 67 | } 68 | 69 | func showSetting() { 70 | err := database.InitDB(config.GetDBPath()) 71 | if err != nil { 72 | fmt.Println(err) 73 | return 74 | } 75 | settingService := service.SettingService{} 76 | allSetting, err := settingService.GetAllSetting() 77 | if err != nil { 78 | fmt.Println("get current port failed,error info:", err) 79 | } 80 | fmt.Println("Current panel settings:") 81 | fmt.Println("\tPanel port:\t", (*allSetting)["webPort"]) 82 | fmt.Println("\tPanel path:\t", (*allSetting)["webPath"]) 83 | if (*allSetting)["webListen"] != "" { 84 | fmt.Println("\tPanel IP:\t", (*allSetting)["webListen"]) 85 | } 86 | if (*allSetting)["webDomain"] != "" { 87 | fmt.Println("\tPanel Domain:\t", (*allSetting)["webDomain"]) 88 | } 89 | if (*allSetting)["webURI"] != "" { 90 | fmt.Println("\tPanel URI:\t", (*allSetting)["webURI"]) 91 | } 92 | fmt.Println() 93 | fmt.Println("Current subscription settings:") 94 | fmt.Println("\tSub port:\t", (*allSetting)["subPort"]) 95 | fmt.Println("\tSub path:\t", (*allSetting)["subPath"]) 96 | if (*allSetting)["subListen"] != "" { 97 | fmt.Println("\tSub IP:\t", (*allSetting)["subListen"]) 98 | } 99 | if (*allSetting)["subDomain"] != "" { 100 | fmt.Println("\tSub Domain:\t", (*allSetting)["subDomain"]) 101 | } 102 | if (*allSetting)["subURI"] != "" { 103 | fmt.Println("\tSub URI:\t", (*allSetting)["subURI"]) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /backend/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | //go:embed version 11 | var version string 12 | 13 | //go:embed name 14 | var name string 15 | 16 | //go:embed config.json 17 | var defaultConfig string 18 | 19 | type LogLevel string 20 | 21 | const ( 22 | Debug LogLevel = "debug" 23 | Info LogLevel = "info" 24 | Warn LogLevel = "warn" 25 | Error LogLevel = "error" 26 | ) 27 | 28 | func GetVersion() string { 29 | return strings.TrimSpace(version) 30 | } 31 | 32 | func GetName() string { 33 | return strings.TrimSpace(name) 34 | } 35 | 36 | func GetLogLevel() LogLevel { 37 | if IsDebug() { 38 | return Debug 39 | } 40 | logLevel := os.Getenv("SUI_LOG_LEVEL") 41 | if logLevel == "" { 42 | return Info 43 | } 44 | return LogLevel(logLevel) 45 | } 46 | 47 | func IsDebug() bool { 48 | return os.Getenv("SUI_DEBUG") == "true" 49 | } 50 | 51 | func GetBinFolderPath() string { 52 | binFolderPath := os.Getenv("SUI_BIN_FOLDER") 53 | if binFolderPath == "" { 54 | binFolderPath = "bin" 55 | } 56 | return binFolderPath 57 | } 58 | 59 | func GetDBFolderPath() string { 60 | dbFolderPath := os.Getenv("SUI_DB_FOLDER") 61 | if dbFolderPath == "" { 62 | dbFolderPath = "/usr/local/s-ui/db" 63 | } 64 | return dbFolderPath 65 | } 66 | 67 | func GetDBPath() string { 68 | return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName()) 69 | } 70 | 71 | func GetDefaultConfig() string { 72 | apiEnv := GetEnvApi() 73 | if len(apiEnv) > 0 { 74 | return strings.Replace(defaultConfig, "127.0.0.1:1080", apiEnv, 1) 75 | } 76 | return defaultConfig 77 | } 78 | 79 | func GetEnvApi() string { 80 | return os.Getenv("SINGBOX_API") 81 | } 82 | -------------------------------------------------------------------------------- /backend/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "log": { 3 | "level": "info" 4 | }, 5 | "dns": {}, 6 | "inbounds": [], 7 | "outbounds": [ 8 | { 9 | "tag": "direct", 10 | "type": "direct" 11 | }, 12 | { 13 | "type": "dns", 14 | "tag": "dns-out" 15 | } 16 | ], 17 | "route": { 18 | "rules": [ 19 | { 20 | "protocol": "dns", 21 | "outbound": "dns-out" 22 | } 23 | ] 24 | }, 25 | "experimental": { 26 | "v2ray_api": { 27 | "listen": "127.0.0.1:1080", 28 | "stats": { 29 | "enabled": true, 30 | "inbounds": [], 31 | "outbounds": [ 32 | "direct" 33 | ], 34 | "users": [] 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /backend/config/name: -------------------------------------------------------------------------------- 1 | s-ui -------------------------------------------------------------------------------- /backend/config/version: -------------------------------------------------------------------------------- 1 | 1.0.0 -------------------------------------------------------------------------------- /backend/cronjob/cronJob.go: -------------------------------------------------------------------------------- 1 | package cronjob 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/robfig/cron/v3" 7 | ) 8 | 9 | type CronJob struct { 10 | cron *cron.Cron 11 | } 12 | 13 | func NewCronJob() *CronJob { 14 | return &CronJob{} 15 | } 16 | 17 | func (c *CronJob) Start(loc *time.Location, trafficAge int) error { 18 | c.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds()) 19 | c.cron.Start() 20 | 21 | go func() { 22 | // Start stats job 23 | c.cron.AddJob("@every 10s", NewStatsJob()) 24 | // Start expiry job 25 | c.cron.AddJob("@every 1m", NewDepleteJob()) 26 | // Start deleting old stats 27 | c.cron.AddJob("@daily", NewDelStatsJob(trafficAge)) 28 | }() 29 | 30 | return nil 31 | } 32 | 33 | func (c *CronJob) Stop() { 34 | if c.cron != nil { 35 | c.cron.Stop() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/cronjob/delStatsJob.go: -------------------------------------------------------------------------------- 1 | package cronjob 2 | 3 | import ( 4 | "s-ui/logger" 5 | "s-ui/service" 6 | ) 7 | 8 | type DelStatsJob struct { 9 | service.StatsService 10 | trafficAge int 11 | } 12 | 13 | func NewDelStatsJob(ta int) *DelStatsJob { 14 | return &DelStatsJob{ 15 | trafficAge: ta, 16 | } 17 | } 18 | 19 | func (s *DelStatsJob) Run() { 20 | err := s.StatsService.DelOldStats(s.trafficAge) 21 | if err != nil { 22 | logger.Warning("Deleting old statistics failed: ", err) 23 | return 24 | } 25 | logger.Debug("Stats older than ", s.trafficAge, " days were deleted") 26 | } 27 | -------------------------------------------------------------------------------- /backend/cronjob/depleteJob.go: -------------------------------------------------------------------------------- 1 | package cronjob 2 | 3 | import ( 4 | "s-ui/logger" 5 | "s-ui/service" 6 | ) 7 | 8 | type DepleteJob struct { 9 | service.ConfigService 10 | } 11 | 12 | func NewDepleteJob() *DepleteJob { 13 | return new(DepleteJob) 14 | } 15 | 16 | func (s *DepleteJob) Run() { 17 | err := s.ConfigService.DepleteClients() 18 | if err != nil { 19 | logger.Warning("Disable depleted users failed: ", err) 20 | return 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/cronjob/statsJob.go: -------------------------------------------------------------------------------- 1 | package cronjob 2 | 3 | import ( 4 | "s-ui/logger" 5 | "s-ui/service" 6 | ) 7 | 8 | type StatsJob struct { 9 | service.SingBoxService 10 | } 11 | 12 | func NewStatsJob() *StatsJob { 13 | return new(StatsJob) 14 | } 15 | 16 | func (s *StatsJob) Run() { 17 | err := s.SingBoxService.GetStats() 18 | if err != nil { 19 | logger.Warning("Get stats failed: ", err) 20 | return 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/database/db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "s-ui/config" 7 | "s-ui/database/model" 8 | 9 | "gorm.io/driver/sqlite" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | ) 13 | 14 | var db *gorm.DB 15 | 16 | func initUser() error { 17 | var count int64 18 | err := db.Model(&model.User{}).Count(&count).Error 19 | if err != nil { 20 | return err 21 | } 22 | if count == 0 { 23 | user := &model.User{ 24 | Username: "admin", 25 | Password: "admin", 26 | } 27 | return db.Create(user).Error 28 | } 29 | return nil 30 | } 31 | 32 | func OpenDB(dbPath string) error { 33 | dir := path.Dir(dbPath) 34 | err := os.MkdirAll(dir, 01740) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | var gormLogger logger.Interface 40 | 41 | if config.IsDebug() { 42 | gormLogger = logger.Default 43 | } else { 44 | gormLogger = logger.Discard 45 | } 46 | 47 | c := &gorm.Config{ 48 | Logger: gormLogger, 49 | } 50 | db, err = gorm.Open(sqlite.Open(dbPath), c) 51 | return err 52 | } 53 | 54 | func InitDB(dbPath string) error { 55 | err := OpenDB(dbPath) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | err = db.AutoMigrate( 61 | &model.Setting{}, 62 | &model.Tls{}, 63 | &model.InboundData{}, 64 | &model.User{}, 65 | &model.Stats{}, 66 | &model.Client{}, 67 | &model.Changes{}, 68 | ) 69 | if err != nil { 70 | return err 71 | } 72 | err = initUser() 73 | if err != nil { 74 | return err 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func GetDB() *gorm.DB { 81 | return db 82 | } 83 | 84 | func IsNotFound(err error) bool { 85 | return err == gorm.ErrRecordNotFound 86 | } 87 | -------------------------------------------------------------------------------- /backend/database/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "encoding/json" 4 | 5 | type Setting struct { 6 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 7 | Key string `json:"key" form:"key"` 8 | Value string `json:"value" form:"value"` 9 | } 10 | 11 | type Tls struct { 12 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 13 | Name string `json:"name" form:"name"` 14 | Inbounds json.RawMessage `json:"inbounds" form:"inbounds"` 15 | Server json.RawMessage `json:"server" form:"server"` 16 | Client json.RawMessage `json:"client" form:"client"` 17 | } 18 | 19 | type InboundData struct { 20 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 21 | Tag string `json:"tag" form:"tag"` 22 | Addrs json.RawMessage `json:"addrs" form:"addrs"` 23 | OutJson json.RawMessage `json:"outJson" form:"outJson"` 24 | } 25 | 26 | type User struct { 27 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 28 | Username string `json:"username" form:"username"` 29 | Password string `json:"password" form:"password"` 30 | LastLogins string `json:"lastLogin"` 31 | } 32 | 33 | type Client struct { 34 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 35 | Enable bool `json:"enable" form:"enable"` 36 | Name string `json:"name" form:"name"` 37 | Config json.RawMessage `json:"config" form:"config"` 38 | Inbounds json.RawMessage `json:"inbounds" form:"inbounds"` 39 | Links json.RawMessage `json:"links" form:"links"` 40 | Volume int64 `json:"volume" form:"volume"` 41 | Expiry int64 `json:"expiry" form:"expiry"` 42 | Down int64 `json:"down" form:"down"` 43 | Up int64 `json:"up" form:"up"` 44 | Desc string `json:"desc" from:"desc"` 45 | } 46 | 47 | type Stats struct { 48 | Id uint64 `json:"id" gorm:"primaryKey;autoIncrement"` 49 | DateTime int64 `json:"dateTime"` 50 | Resource string `json:"resource"` 51 | Tag string `json:"tag"` 52 | Direction bool `json:"direction"` 53 | Traffic int64 `json:"traffic"` 54 | } 55 | 56 | type Changes struct { 57 | Id uint64 `json:"id" gorm:"primaryKey;autoIncrement"` 58 | DateTime int64 `json:"dateTime"` 59 | Actor string `json:"Actor"` 60 | Key string `json:"key" form:"key"` 61 | Action string `json:"action" form:"action"` 62 | Index uint `json:"index" form:"index"` 63 | Obj json.RawMessage `json:"obj" form:"obj"` 64 | } 65 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module s-ui 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/gin-contrib/gzip v0.0.6 7 | github.com/gin-gonic/gin v1.9.1 8 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 9 | github.com/v2fly/v2ray-core/v5 v5.13.0 10 | gorm.io/driver/sqlite v1.5.5 11 | gorm.io/gorm v1.25.7 12 | ) 13 | 14 | require ( 15 | github.com/adrg/xdg v0.4.0 // indirect 16 | github.com/bytedance/sonic v1.11.1 // indirect 17 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 18 | github.com/chenzhuoyu/iasm v0.9.1 // indirect 19 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 20 | github.com/go-ole/go-ole v1.3.0 // indirect 21 | github.com/go-playground/locales v0.14.1 // indirect 22 | github.com/go-playground/universal-translator v0.18.1 // indirect 23 | github.com/goccy/go-json v0.10.2 // indirect 24 | github.com/golang/protobuf v1.5.3 // indirect 25 | github.com/gorilla/context v1.1.2 // indirect 26 | github.com/gorilla/securecookie v1.1.2 // indirect 27 | github.com/gorilla/sessions v1.2.2 // indirect 28 | github.com/json-iterator/go v1.1.12 // indirect 29 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 30 | github.com/leodido/go-urn v1.4.0 // indirect 31 | github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 34 | github.com/modern-go/reflect2 v1.0.2 // indirect 35 | github.com/pires/go-proxyproto v0.7.0 // indirect 36 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 37 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 38 | github.com/tklauser/go-sysconf v0.3.13 // indirect 39 | github.com/tklauser/numcpus v0.7.0 // indirect 40 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 41 | github.com/ugorji/go/codec v1.2.12 // indirect 42 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 43 | golang.org/x/arch v0.7.0 // indirect 44 | golang.org/x/crypto v0.21.0 // indirect 45 | golang.org/x/net v0.23.0 // indirect 46 | golang.org/x/sys v0.18.0 // indirect 47 | golang.org/x/text v0.14.0 // indirect 48 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240221002015-b0ce06bbee7c // indirect 49 | google.golang.org/protobuf v1.33.0 // indirect 50 | gopkg.in/yaml.v3 v3.0.1 // indirect 51 | ) 52 | 53 | require ( 54 | github.com/Calidity/gin-sessions v1.3.1 55 | github.com/gin-contrib/sse v0.1.0 // indirect 56 | github.com/go-playground/validator/v10 v10.18.0 // indirect 57 | github.com/jinzhu/inflection v1.0.0 // indirect 58 | github.com/jinzhu/now v1.1.5 // indirect 59 | github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect 60 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 61 | github.com/robfig/cron/v3 v3.0.1 62 | github.com/shirou/gopsutil/v3 v3.24.1 63 | google.golang.org/grpc v1.62.0 64 | ) 65 | -------------------------------------------------------------------------------- /backend/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/op/go-logging" 9 | ) 10 | 11 | var logger *logging.Logger 12 | var logBuffer []struct { 13 | time string 14 | level logging.Level 15 | log string 16 | } 17 | 18 | func init() { 19 | InitLogger(logging.INFO) 20 | } 21 | 22 | func InitLogger(level logging.Level) { 23 | newLogger := logging.MustGetLogger("s-ui") 24 | var err error 25 | var backend logging.Backend 26 | var format logging.Formatter 27 | ppid := os.Getppid() 28 | 29 | backend, err = logging.NewSyslogBackend("") 30 | if err != nil { 31 | println(err) 32 | backend = logging.NewLogBackend(os.Stderr, "", 0) 33 | } 34 | if ppid > 0 && err != nil { 35 | format = logging.MustStringFormatter(`%{time:2006/01/02 15:04:05} %{level} - %{message}`) 36 | } else { 37 | format = logging.MustStringFormatter(`%{level} - %{message}`) 38 | } 39 | 40 | backendFormatter := logging.NewBackendFormatter(backend, format) 41 | backendLeveled := logging.AddModuleLevel(backendFormatter) 42 | backendLeveled.SetLevel(level, "s-ui") 43 | newLogger.SetBackend(backendLeveled) 44 | 45 | logger = newLogger 46 | } 47 | 48 | func Debug(args ...interface{}) { 49 | logger.Debug(args...) 50 | addToBuffer("DEBUG", fmt.Sprint(args...)) 51 | } 52 | 53 | func Debugf(format string, args ...interface{}) { 54 | logger.Debugf(format, args...) 55 | addToBuffer("DEBUG", fmt.Sprintf(format, args...)) 56 | } 57 | 58 | func Info(args ...interface{}) { 59 | logger.Info(args...) 60 | addToBuffer("INFO", fmt.Sprint(args...)) 61 | } 62 | 63 | func Infof(format string, args ...interface{}) { 64 | logger.Infof(format, args...) 65 | addToBuffer("INFO", fmt.Sprintf(format, args...)) 66 | } 67 | 68 | func Warning(args ...interface{}) { 69 | logger.Warning(args...) 70 | addToBuffer("WARNING", fmt.Sprint(args...)) 71 | } 72 | 73 | func Warningf(format string, args ...interface{}) { 74 | logger.Warningf(format, args...) 75 | addToBuffer("WARNING", fmt.Sprintf(format, args...)) 76 | } 77 | 78 | func Error(args ...interface{}) { 79 | logger.Error(args...) 80 | addToBuffer("ERROR", fmt.Sprint(args...)) 81 | } 82 | 83 | func Errorf(format string, args ...interface{}) { 84 | logger.Errorf(format, args...) 85 | addToBuffer("ERROR", fmt.Sprintf(format, args...)) 86 | } 87 | 88 | func addToBuffer(level string, newLog string) { 89 | t := time.Now() 90 | if len(logBuffer) >= 10240 { 91 | logBuffer = logBuffer[1:] 92 | } 93 | 94 | logLevel, _ := logging.LogLevel(level) 95 | logBuffer = append(logBuffer, struct { 96 | time string 97 | level logging.Level 98 | log string 99 | }{ 100 | time: t.Format("2006/01/02 15:04:05"), 101 | level: logLevel, 102 | log: newLog, 103 | }) 104 | } 105 | 106 | func GetLogs(c int, level string) []string { 107 | var output []string 108 | logLevel, _ := logging.LogLevel(level) 109 | 110 | for i := len(logBuffer) - 1; i >= 0 && len(output) <= c; i-- { 111 | if logBuffer[i].level <= logLevel { 112 | output = append(output, fmt.Sprintf("%s %s - %s", logBuffer[i].time, logBuffer[i].level, logBuffer[i].log)) 113 | } 114 | } 115 | return output 116 | } 117 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | "s-ui/app" 8 | "s-ui/cmd" 9 | "syscall" 10 | ) 11 | 12 | func runApp() { 13 | app := app.NewApp() 14 | 15 | err := app.Init() 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | 20 | err = app.Start() 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | sigCh := make(chan os.Signal, 1) 26 | // Trap shutdown signals 27 | signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM) 28 | for { 29 | sig := <-sigCh 30 | 31 | switch sig { 32 | case syscall.SIGHUP: 33 | app.RestartApp() 34 | default: 35 | app.Stop() 36 | return 37 | } 38 | } 39 | } 40 | 41 | func main() { 42 | if len(os.Args) < 2 { 43 | runApp() 44 | return 45 | } else { 46 | cmd.ParseCmd() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/middleware/domainValidator.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | func DomainValidator(domain string) gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | host := strings.Split(c.Request.Host, ":")[0] 13 | 14 | if host != domain { 15 | c.AbortWithStatus(http.StatusForbidden) 16 | return 17 | } 18 | 19 | c.Next() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/network/auto_https_conn.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "sync" 10 | ) 11 | 12 | type AutoHttpsConn struct { 13 | net.Conn 14 | 15 | firstBuf []byte 16 | bufStart int 17 | 18 | readRequestOnce sync.Once 19 | } 20 | 21 | func NewAutoHttpsConn(conn net.Conn) net.Conn { 22 | return &AutoHttpsConn{ 23 | Conn: conn, 24 | } 25 | } 26 | 27 | func (c *AutoHttpsConn) readRequest() bool { 28 | c.firstBuf = make([]byte, 2048) 29 | n, err := c.Conn.Read(c.firstBuf) 30 | c.firstBuf = c.firstBuf[:n] 31 | if err != nil { 32 | return false 33 | } 34 | reader := bytes.NewReader(c.firstBuf) 35 | bufReader := bufio.NewReader(reader) 36 | request, err := http.ReadRequest(bufReader) 37 | if err != nil { 38 | return false 39 | } 40 | resp := http.Response{ 41 | Header: http.Header{}, 42 | } 43 | resp.StatusCode = http.StatusTemporaryRedirect 44 | location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI) 45 | resp.Header.Set("Location", location) 46 | resp.Write(c.Conn) 47 | c.Close() 48 | c.firstBuf = nil 49 | return true 50 | } 51 | 52 | func (c *AutoHttpsConn) Read(buf []byte) (int, error) { 53 | c.readRequestOnce.Do(func() { 54 | c.readRequest() 55 | }) 56 | 57 | if c.firstBuf != nil { 58 | n := copy(buf, c.firstBuf[c.bufStart:]) 59 | c.bufStart += n 60 | if c.bufStart >= len(c.firstBuf) { 61 | c.firstBuf = nil 62 | } 63 | return n, nil 64 | } 65 | 66 | return c.Conn.Read(buf) 67 | } 68 | -------------------------------------------------------------------------------- /backend/network/auto_https_listener.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import "net" 4 | 5 | type AutoHttpsListener struct { 6 | net.Listener 7 | } 8 | 9 | func NewAutoHttpsListener(listener net.Listener) net.Listener { 10 | return &AutoHttpsListener{ 11 | Listener: listener, 12 | } 13 | } 14 | 15 | func (l *AutoHttpsListener) Accept() (net.Conn, error) { 16 | conn, err := l.Listener.Accept() 17 | if err != nil { 18 | return nil, err 19 | } 20 | return NewAutoHttpsConn(conn), nil 21 | } 22 | -------------------------------------------------------------------------------- /backend/service/client.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "s-ui/database" 6 | "s-ui/database/model" 7 | "s-ui/logger" 8 | "time" 9 | 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type ClientService struct { 14 | } 15 | 16 | func (s *ClientService) GetAll() ([]model.Client, error) { 17 | db := database.GetDB() 18 | clients := []model.Client{} 19 | err := db.Model(model.Client{}).Scan(&clients).Error 20 | if err != nil { 21 | return nil, err 22 | } 23 | return clients, nil 24 | } 25 | 26 | func (s *ClientService) Save(tx *gorm.DB, changes []model.Changes) error { 27 | var err error 28 | for _, change := range changes { 29 | client := model.Client{} 30 | err = json.Unmarshal(change.Obj, &client) 31 | if err != nil { 32 | return err 33 | } 34 | switch change.Action { 35 | case "new": 36 | err = tx.Create(&client).Error 37 | case "del": 38 | err = tx.Where("id = ?", change.Index).Delete(model.Client{}).Error 39 | default: 40 | err = tx.Save(client).Error 41 | } 42 | if err != nil { 43 | return err 44 | } 45 | } 46 | return err 47 | } 48 | 49 | func (s *ClientService) DepleteClients() ([]string, []string, error) { 50 | var err error 51 | var clients []model.Client 52 | var changes []model.Changes 53 | now := time.Now().Unix() 54 | db := database.GetDB() 55 | err = db.Model(model.Client{}).Where("enable = true AND ((volume >0 AND up+down > volume) OR (expiry > 0 AND expiry < ?))", now).Scan(&clients).Error 56 | if err != nil { 57 | return nil, nil, err 58 | } 59 | 60 | dt := time.Now().Unix() 61 | var users, inbounds []string 62 | for _, client := range clients { 63 | logger.Debug("Client ", client.Name, " is going to be disabled") 64 | users = append(users, client.Name) 65 | var userInbounds []string 66 | json.Unmarshal(client.Inbounds, &userInbounds) 67 | inbounds = append(inbounds, userInbounds...) 68 | changes = append(changes, model.Changes{ 69 | DateTime: dt, 70 | Actor: "DepleteJob", 71 | Key: "clients", 72 | Action: "disable", 73 | Obj: json.RawMessage("\"" + client.Name + "\""), 74 | }) 75 | } 76 | 77 | // Save changes 78 | if len(changes) > 0 { 79 | err = db.Model(model.Client{}).Where("enable = true AND ((volume >0 AND up+down > volume) OR (expiry > 0 AND expiry < ?))", now).Update("enable", false).Error 80 | if err != nil { 81 | return nil, nil, err 82 | } 83 | err = db.Model(model.Changes{}).Create(&changes).Error 84 | if err != nil { 85 | return nil, nil, err 86 | } 87 | LastUpdate = dt 88 | } 89 | 90 | return users, inbounds, nil 91 | } 92 | -------------------------------------------------------------------------------- /backend/service/inData.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "s-ui/database" 6 | "s-ui/database/model" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type InDataService struct { 12 | } 13 | 14 | func (s *InDataService) GetAll() ([]model.InboundData, error) { 15 | db := database.GetDB() 16 | inData := []model.InboundData{} 17 | err := db.Model(model.InboundData{}).Scan(&inData).Error 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return inData, nil 23 | } 24 | 25 | func (s *InDataService) Save(tx *gorm.DB, changes []model.Changes) error { 26 | var err error 27 | for _, change := range changes { 28 | inData := model.InboundData{} 29 | err = json.Unmarshal(change.Obj, &inData) 30 | if err != nil { 31 | return err 32 | } 33 | switch change.Action { 34 | case "new": 35 | err = tx.Create(&inData).Error 36 | case "del": 37 | err = tx.Where("id = ?", change.Index).Delete(model.InboundData{}).Error 38 | default: 39 | err = tx.Save(inData).Error 40 | } 41 | if err != nil { 42 | return err 43 | } 44 | } 45 | return err 46 | } 47 | -------------------------------------------------------------------------------- /backend/service/panel.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "os" 5 | "s-ui/logger" 6 | "syscall" 7 | "time" 8 | ) 9 | 10 | type PanelService struct { 11 | } 12 | 13 | func (s *PanelService) RestartPanel(delay time.Duration) error { 14 | p, err := os.FindProcess(syscall.Getpid()) 15 | if err != nil { 16 | return err 17 | } 18 | go func() { 19 | time.Sleep(delay) 20 | err := p.Signal(syscall.SIGHUP) 21 | if err != nil { 22 | logger.Error("send signal SIGHUP failed:", err) 23 | } 24 | }() 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /backend/service/sinxbox.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "s-ui/singbox" 5 | ) 6 | 7 | type SingBoxService struct { 8 | singbox.V2rayAPI 9 | singbox.Controller 10 | StatsService 11 | } 12 | 13 | func (s *SingBoxService) GetStats() error { 14 | s.V2rayAPI.Init(ApiAddr) 15 | defer s.V2rayAPI.Close() 16 | stats, err := s.V2rayAPI.GetStats(true) 17 | if err != nil { 18 | return err 19 | } 20 | err = s.StatsService.SaveStats(stats) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return nil 26 | } 27 | 28 | func (s *SingBoxService) GetSysStats() (*map[string]interface{}, error) { 29 | err := s.V2rayAPI.Init(ApiAddr) 30 | if err != nil { 31 | return nil, err 32 | } 33 | defer s.V2rayAPI.Close() 34 | resp, err := s.V2rayAPI.GetSysStats() 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | result := make(map[string]interface{}) 40 | result["NumGoroutine"] = resp.NumGoroutine 41 | result["Alloc"] = resp.Alloc 42 | result["Uptime"] = resp.Uptime 43 | 44 | return &result, nil 45 | } 46 | -------------------------------------------------------------------------------- /backend/service/stats.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "s-ui/database" 5 | "s-ui/database/model" 6 | "time" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type onlines struct { 12 | Inbound []string `json:"inbound,omitempty"` 13 | User []string `json:"user,omitempty"` 14 | Outbound []string `json:"outbound,omitempty"` 15 | } 16 | 17 | var onlineResources = &onlines{} 18 | 19 | type StatsService struct { 20 | } 21 | 22 | func (s *StatsService) SaveStats(stats []*model.Stats) error { 23 | var err error 24 | 25 | // Reset onlines 26 | onlineResources.Inbound = nil 27 | onlineResources.Outbound = nil 28 | onlineResources.User = nil 29 | 30 | if len(stats) == 0 { 31 | return nil 32 | } 33 | 34 | db := database.GetDB() 35 | tx := db.Begin() 36 | defer func() { 37 | if err == nil { 38 | tx.Commit() 39 | } else { 40 | tx.Rollback() 41 | } 42 | }() 43 | 44 | for _, stat := range stats { 45 | if stat.Resource == "user" { 46 | if stat.Direction { 47 | err = tx.Model(model.Client{}).Where("name = ?", stat.Tag). 48 | UpdateColumn("up", gorm.Expr("up + ?", stat.Traffic)).Error 49 | } else { 50 | err = tx.Model(model.Client{}).Where("name = ?", stat.Tag). 51 | UpdateColumn("down", gorm.Expr("down + ?", stat.Traffic)).Error 52 | } 53 | if err != nil { 54 | return err 55 | } 56 | } 57 | if stat.Direction { 58 | switch stat.Resource { 59 | case "inbound": 60 | onlineResources.Inbound = append(onlineResources.Inbound, stat.Tag) 61 | case "outbound": 62 | onlineResources.Outbound = append(onlineResources.Outbound, stat.Tag) 63 | case "user": 64 | onlineResources.User = append(onlineResources.User, stat.Tag) 65 | } 66 | } 67 | } 68 | 69 | err = tx.Create(&stats).Error 70 | return err 71 | } 72 | 73 | func (s *StatsService) GetStats(resorce string, tag string, limit int) ([]model.Stats, error) { 74 | var err error 75 | var result []model.Stats 76 | 77 | currentTime := time.Now().Unix() 78 | timeDiff := currentTime - (int64(limit) * 3600) 79 | 80 | db := database.GetDB() 81 | err = db.Model(model.Stats{}).Where("resource = ? AND tag = ? AND date_time > ?", resorce, tag, timeDiff).Scan(&result).Error 82 | if err != nil { 83 | return nil, err 84 | } 85 | return result, nil 86 | } 87 | 88 | func (s *StatsService) GetOnlines() (onlines, error) { 89 | return *onlineResources, nil 90 | } 91 | func (s *StatsService) DelOldStats(days int) error { 92 | oldTime := time.Now().AddDate(0, 0, -(days)).Unix() 93 | db := database.GetDB() 94 | return db.Where("date_time < ?", oldTime).Delete(model.Stats{}).Error 95 | } 96 | -------------------------------------------------------------------------------- /backend/service/tls.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "s-ui/database" 6 | "s-ui/database/model" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type TlsService struct { 12 | } 13 | 14 | func (s *TlsService) GetAll() ([]model.Tls, error) { 15 | db := database.GetDB() 16 | tlsConfig := []model.Tls{} 17 | err := db.Model(model.Tls{}).Scan(&tlsConfig).Error 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return tlsConfig, nil 23 | } 24 | 25 | func (s *TlsService) Save(tx *gorm.DB, changes []model.Changes) error { 26 | var err error 27 | for _, change := range changes { 28 | tlsConfig := model.Tls{} 29 | err = json.Unmarshal(change.Obj, &tlsConfig) 30 | if err != nil { 31 | return err 32 | } 33 | switch change.Action { 34 | case "new": 35 | err = tx.Create(&tlsConfig).Error 36 | case "del": 37 | err = tx.Where("id = ?", change.Index).Delete(model.Tls{}).Error 38 | default: 39 | err = tx.Save(tlsConfig).Error 40 | } 41 | if err != nil { 42 | return err 43 | } 44 | } 45 | return err 46 | } 47 | -------------------------------------------------------------------------------- /backend/service/user.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "s-ui/database" 5 | "s-ui/database/model" 6 | "s-ui/logger" 7 | "s-ui/util/common" 8 | "time" 9 | 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type UserService struct { 14 | } 15 | 16 | func (s *UserService) GetFirstUser() (*model.User, error) { 17 | db := database.GetDB() 18 | 19 | user := &model.User{} 20 | err := db.Model(model.User{}). 21 | First(user). 22 | Error 23 | if err != nil { 24 | return nil, err 25 | } 26 | return user, nil 27 | } 28 | 29 | func (s *UserService) UpdateFirstUser(username string, password string) error { 30 | if username == "" { 31 | return common.NewError("username can not be empty") 32 | } else if password == "" { 33 | return common.NewError("password can not be empty") 34 | } 35 | db := database.GetDB() 36 | user := &model.User{} 37 | err := db.Model(model.User{}).First(user).Error 38 | if database.IsNotFound(err) { 39 | user.Username = username 40 | user.Password = password 41 | return db.Model(model.User{}).Create(user).Error 42 | } else if err != nil { 43 | return err 44 | } 45 | user.Username = username 46 | user.Password = password 47 | return db.Save(user).Error 48 | } 49 | 50 | func (s *UserService) Login(username string, password string, remoteIP string) (string, error) { 51 | user := s.CheckUser(username, password, remoteIP) 52 | if user == nil { 53 | return "", common.NewError("wrong user or password! IP: ", remoteIP) 54 | } 55 | return user.Username, nil 56 | } 57 | 58 | func (s *UserService) CheckUser(username string, password string, remoteIP string) *model.User { 59 | db := database.GetDB() 60 | 61 | user := &model.User{} 62 | err := db.Model(model.User{}). 63 | Where("username = ? and password = ?", username, password). 64 | First(user). 65 | Error 66 | if err == gorm.ErrRecordNotFound { 67 | return nil 68 | } else if err != nil { 69 | logger.Warning("check user err:", err, " IP: ", remoteIP) 70 | return nil 71 | } 72 | 73 | lastLoginTxt := time.Now().Format("2006-01-02 15:04:05") + " " + remoteIP 74 | err = db.Model(model.User{}). 75 | Where("username = ?", username). 76 | Update("last_logins", &lastLoginTxt).Error 77 | if err != nil { 78 | logger.Warning("unable to log login data", err) 79 | } 80 | return user 81 | } 82 | 83 | func (s *UserService) GetUsers() (*[]model.User, error) { 84 | var users []model.User 85 | db := database.GetDB() 86 | err := db.Model(model.User{}).Select("id,username,last_logins").Scan(&users).Error 87 | if err != nil { 88 | return nil, err 89 | } 90 | return &users, nil 91 | } 92 | 93 | func (s *UserService) ChangePass(id string, oldPass string, newUser string, newPass string) error { 94 | db := database.GetDB() 95 | user := &model.User{} 96 | err := db.Model(model.User{}).Where("id = ? AND password = ?", id, oldPass).First(user).Error 97 | if err != nil || database.IsNotFound(err) { 98 | return err 99 | } 100 | user.Username = newUser 101 | user.Password = newPass 102 | return db.Save(user).Error 103 | } 104 | -------------------------------------------------------------------------------- /backend/singbox/controller.go: -------------------------------------------------------------------------------- 1 | package singbox 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "os/exec" 8 | "s-ui/config" 9 | "strings" 10 | ) 11 | 12 | var serviceName = "sing-box" 13 | 14 | type Controller struct { 15 | } 16 | 17 | func (s *Controller) GetBinaryName() string { 18 | return "sing-box" 19 | } 20 | 21 | func (s *Controller) GetBinaryPath() string { 22 | return config.GetBinFolderPath() + "/" + s.GetBinaryName() 23 | } 24 | 25 | func (s *Controller) GetConfigPath() string { 26 | return config.GetBinFolderPath() + "/config.json" 27 | } 28 | 29 | func (s *Controller) IsRunning() bool { 30 | cmd := exec.Command("pgrep", "sing-box") 31 | output, err := cmd.Output() 32 | if err != nil { 33 | return false 34 | } 35 | 36 | // If pgrep found the Controller, its output will not be empty 37 | return strings.TrimSpace(string(output)) != "" 38 | } 39 | 40 | func (s *Controller) signalSingbox(signal string) error { 41 | return os.WriteFile(config.GetBinFolderPath()+"/signal", []byte(signal), fs.ModePerm) 42 | } 43 | 44 | func (s *Controller) Restart() error { 45 | return s.signalSingbox("restart") 46 | } 47 | 48 | func (s *Controller) Stop() error { 49 | if !s.IsRunning() { 50 | return errors.New("Sing-Box is not running") 51 | } 52 | 53 | return s.signalSingbox("stop") 54 | } 55 | -------------------------------------------------------------------------------- /backend/singbox/v2rayApi.go: -------------------------------------------------------------------------------- 1 | package singbox 2 | 3 | import ( 4 | "context" 5 | 6 | "regexp" 7 | "s-ui/database/model" 8 | "s-ui/util/common" 9 | "time" 10 | 11 | statsService "github.com/v2fly/v2ray-core/v5/app/stats/command" 12 | "google.golang.org/grpc" 13 | ) 14 | 15 | type V2rayAPI struct { 16 | StatsServiceClient *statsService.StatsServiceClient 17 | grpcClient *grpc.ClientConn 18 | isConnected bool 19 | } 20 | 21 | func (v *V2rayAPI) Init(ApiAddr string) (err error) { 22 | if len(ApiAddr) == 0 { 23 | return common.NewError("The api address is wrong: ", ApiAddr) 24 | } 25 | v.grpcClient, err = grpc.Dial(ApiAddr, grpc.WithInsecure()) 26 | if err != nil { 27 | return err 28 | } 29 | v.isConnected = true 30 | 31 | ssClient := statsService.NewStatsServiceClient(v.grpcClient) 32 | 33 | v.StatsServiceClient = &ssClient 34 | 35 | return 36 | } 37 | 38 | func (v *V2rayAPI) Close() { 39 | v.grpcClient.Close() 40 | v.StatsServiceClient = nil 41 | v.isConnected = false 42 | } 43 | 44 | func (v *V2rayAPI) GetStats(reset bool) ([]*model.Stats, error) { 45 | if v.grpcClient == nil { 46 | return nil, common.NewError("v2ray api is not initialized") 47 | } 48 | var trafficRegex = regexp.MustCompile("(inbound|outbound|user)>>>([^>]+)>>>traffic>>>(downlink|uplink)") 49 | 50 | client := *v.StatsServiceClient 51 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 52 | defer cancel() 53 | request := &statsService.QueryStatsRequest{ 54 | Reset_: reset, 55 | } 56 | resp, err := client.QueryStats(ctx, request) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | dt := time.Now().Unix() 62 | stats := make([]*model.Stats, 0) 63 | for _, stat := range resp.GetStat() { 64 | if stat.Value > 0 { 65 | matchs := trafficRegex.FindStringSubmatch(stat.Name) 66 | if len(matchs) > 3 { 67 | stat := model.Stats{ 68 | DateTime: dt, 69 | Resource: matchs[1], 70 | Tag: matchs[2], 71 | Direction: matchs[3] == "uplink", 72 | Traffic: stat.Value, 73 | } 74 | stats = append(stats, &stat) 75 | } 76 | } 77 | } 78 | 79 | return stats, nil 80 | } 81 | 82 | func (v *V2rayAPI) GetSysStats() (*statsService.SysStatsResponse, error) { 83 | if v.grpcClient == nil { 84 | return nil, common.NewError("v2ray api is not initialized") 85 | } 86 | client := *v.StatsServiceClient 87 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 88 | defer cancel() 89 | request := &statsService.SysStatsRequest{} 90 | resp, err := client.GetSysStats(ctx, request) 91 | if err != nil { 92 | return nil, err 93 | } 94 | return resp, nil 95 | } 96 | -------------------------------------------------------------------------------- /backend/sub/linkService.go: -------------------------------------------------------------------------------- 1 | package sub 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "s-ui/logger" 9 | "s-ui/util" 10 | "strings" 11 | ) 12 | 13 | type Link struct { 14 | Type string `json:"type"` 15 | Remark string `json:"remark"` 16 | Uri string `json:"uri"` 17 | } 18 | 19 | type LinkService struct { 20 | } 21 | 22 | func (s *LinkService) GetLinks(linkJson *json.RawMessage, types string, clientInfo string) []string { 23 | links := []Link{} 24 | var result []string 25 | err := json.Unmarshal(*linkJson, &links) 26 | if err != nil { 27 | return nil 28 | } 29 | for _, link := range links { 30 | switch link.Type { 31 | case "external": 32 | result = append(result, link.Uri) 33 | case "sub": 34 | result = append(result, s.getExternalSub(link.Uri)...) 35 | case "local": 36 | if types == "all" { 37 | result = append(result, s.addClientInfo(link.Uri, clientInfo)) 38 | } 39 | } 40 | } 41 | return result 42 | } 43 | 44 | func (s *LinkService) addClientInfo(uri string, clientInfo string) string { 45 | protocol := strings.Split(uri, "://") 46 | if len(protocol) < 2 { 47 | return uri 48 | } 49 | switch protocol[0] { 50 | case "vmess": 51 | var vmessJson map[string]interface{} 52 | config, err := util.B64StrToByte(protocol[1]) 53 | if err != nil { 54 | logger.Warning("sub: Error decoding vmess content:", err) 55 | return uri 56 | } 57 | err = json.Unmarshal(config, &vmessJson) 58 | if err != nil { 59 | logger.Warning("sub: Error decoding vmess content:", err) 60 | return uri 61 | } 62 | vmessJson["ps"] = vmessJson["ps"].(string) + clientInfo 63 | result, err := json.MarshalIndent(vmessJson, "", " ") 64 | if err != nil { 65 | logger.Warning("sub: Error decoding vmess + clientInfo content:", err) 66 | return uri 67 | } 68 | return "vmess://" + util.ByteToB64Str(result) 69 | default: 70 | return uri + clientInfo 71 | } 72 | } 73 | 74 | func (s *LinkService) getExternalSub(url string) []string { 75 | tr := &http.Transport{ 76 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 77 | } 78 | 79 | client := &http.Client{Transport: tr} 80 | 81 | // Make the HTTP request 82 | response, err := client.Get(url) 83 | if err != nil { 84 | logger.Warning("sub: Error making HTTP request:", err) 85 | return nil 86 | } 87 | defer response.Body.Close() 88 | 89 | // Read the response body 90 | body, err := io.ReadAll(response.Body) 91 | if err != nil { 92 | logger.Warning("sub: Error reading response body:", err) 93 | return nil 94 | } 95 | 96 | // Convert if the content is Base64 encoded 97 | links := util.StrOrBase64Encoded(string(body)) 98 | return strings.Split(links, "\n") 99 | 100 | } 101 | -------------------------------------------------------------------------------- /backend/sub/sub.go: -------------------------------------------------------------------------------- 1 | package sub 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "io" 7 | "net" 8 | "net/http" 9 | "s-ui/config" 10 | "s-ui/logger" 11 | "s-ui/middleware" 12 | "s-ui/network" 13 | "s-ui/service" 14 | "strconv" 15 | 16 | "github.com/gin-gonic/gin" 17 | ) 18 | 19 | type Server struct { 20 | httpServer *http.Server 21 | listener net.Listener 22 | ctx context.Context 23 | cancel context.CancelFunc 24 | 25 | service.SettingService 26 | } 27 | 28 | func NewServer() *Server { 29 | ctx, cancel := context.WithCancel(context.Background()) 30 | return &Server{ 31 | ctx: ctx, 32 | cancel: cancel, 33 | } 34 | } 35 | 36 | func (s *Server) initRouter() (*gin.Engine, error) { 37 | if config.IsDebug() { 38 | gin.SetMode(gin.DebugMode) 39 | } else { 40 | gin.DefaultWriter = io.Discard 41 | gin.DefaultErrorWriter = io.Discard 42 | gin.SetMode(gin.ReleaseMode) 43 | } 44 | 45 | engine := gin.Default() 46 | 47 | subPath, err := s.SettingService.GetSubPath() 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | subDomain, err := s.SettingService.GetSubDomain() 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | if subDomain != "" { 58 | engine.Use(middleware.DomainValidator(subDomain)) 59 | } 60 | 61 | g := engine.Group(subPath) 62 | NewSubHandler(g) 63 | 64 | return engine, nil 65 | } 66 | 67 | func (s *Server) Start() (err error) { 68 | //This is an anonymous function, no function name 69 | defer func() { 70 | if err != nil { 71 | s.Stop() 72 | } 73 | }() 74 | 75 | engine, err := s.initRouter() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | certFile, err := s.SettingService.GetSubCertFile() 81 | if err != nil { 82 | return err 83 | } 84 | keyFile, err := s.SettingService.GetSubKeyFile() 85 | if err != nil { 86 | return err 87 | } 88 | listen, err := s.SettingService.GetSubListen() 89 | if err != nil { 90 | return err 91 | } 92 | port, err := s.SettingService.GetSubPort() 93 | if err != nil { 94 | return err 95 | } 96 | 97 | listenAddr := net.JoinHostPort(listen, strconv.Itoa(port)) 98 | listener, err := net.Listen("tcp", listenAddr) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | if certFile != "" || keyFile != "" { 104 | cert, err := tls.LoadX509KeyPair(certFile, keyFile) 105 | if err != nil { 106 | listener.Close() 107 | return err 108 | } 109 | c := &tls.Config{ 110 | Certificates: []tls.Certificate{cert}, 111 | } 112 | listener = network.NewAutoHttpsListener(listener) 113 | listener = tls.NewListener(listener, c) 114 | } 115 | 116 | if certFile != "" || keyFile != "" { 117 | logger.Info("Sub server run https on", listener.Addr()) 118 | } else { 119 | logger.Info("Sub server run http on", listener.Addr()) 120 | } 121 | s.listener = listener 122 | 123 | s.httpServer = &http.Server{ 124 | Handler: engine, 125 | } 126 | 127 | go func() { 128 | s.httpServer.Serve(listener) 129 | }() 130 | 131 | return nil 132 | } 133 | 134 | func (s *Server) Stop() error { 135 | s.cancel() 136 | var err error 137 | if s.httpServer != nil { 138 | err = s.httpServer.Shutdown(s.ctx) 139 | if err != nil { 140 | return err 141 | } 142 | } 143 | if s.listener != nil { 144 | err = s.listener.Close() 145 | if err != nil { 146 | return err 147 | } 148 | } 149 | return nil 150 | } 151 | 152 | func (s *Server) GetCtx() context.Context { 153 | return s.ctx 154 | } 155 | -------------------------------------------------------------------------------- /backend/sub/subHandler.go: -------------------------------------------------------------------------------- 1 | package sub 2 | 3 | import ( 4 | "s-ui/logger" 5 | "s-ui/service" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type SubHandler struct { 11 | service.SettingService 12 | SubService 13 | JsonService 14 | } 15 | 16 | func NewSubHandler(g *gin.RouterGroup) { 17 | a := &SubHandler{} 18 | a.initRouter(g) 19 | } 20 | 21 | func (s *SubHandler) initRouter(g *gin.RouterGroup) { 22 | g.GET("/:subid", s.subs) 23 | } 24 | 25 | func (s *SubHandler) subs(c *gin.Context) { 26 | subId := c.Param("subid") 27 | format, isFormat := c.GetQuery("format") 28 | if isFormat { 29 | result, err := s.JsonService.GetJson(subId, format) 30 | if err != nil || result == nil { 31 | logger.Error(err) 32 | c.String(400, "Error!") 33 | } else { 34 | c.String(200, *result) 35 | } 36 | } else { 37 | result, headers, err := s.SubService.GetSubs(subId) 38 | if err != nil || result == nil { 39 | logger.Error(err) 40 | c.String(400, "Error!") 41 | } else { 42 | 43 | // Add headers 44 | c.Writer.Header().Set("Subscription-Userinfo", headers[0]) 45 | c.Writer.Header().Set("Profile-Update-Interval", headers[1]) 46 | c.Writer.Header().Set("Profile-Title", headers[2]) 47 | 48 | c.String(200, *result) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/sub/subService.go: -------------------------------------------------------------------------------- 1 | package sub 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "s-ui/database" 7 | "s-ui/database/model" 8 | "s-ui/service" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type SubService struct { 14 | service.SettingService 15 | LinkService 16 | } 17 | 18 | func (s *SubService) GetSubs(subId string) (*string, []string, error) { 19 | var err error 20 | 21 | db := database.GetDB() 22 | client := &model.Client{} 23 | err = db.Model(model.Client{}).Where("enable = true and name = ?", subId).First(client).Error 24 | if err != nil { 25 | return nil, nil, err 26 | } 27 | 28 | clientInfo := "" 29 | subShowInfo, _ := s.SettingService.GetSubShowInfo() 30 | if subShowInfo { 31 | clientInfo = s.getClientInfo(client) 32 | } 33 | 34 | linksArray := s.LinkService.GetLinks(&client.Links, "all", clientInfo) 35 | result := strings.Join(linksArray, "\n") 36 | 37 | var headers []string 38 | updateInterval, _ := s.SettingService.GetSubUpdates() 39 | headers = append(headers, fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", client.Up, client.Down, client.Volume, client.Expiry)) 40 | headers = append(headers, fmt.Sprintf("%d", updateInterval)) 41 | headers = append(headers, subId) 42 | 43 | subEncode, _ := s.SettingService.GetSubEncode() 44 | if subEncode { 45 | result = base64.StdEncoding.EncodeToString([]byte(result)) 46 | } 47 | 48 | return &result, headers, nil 49 | } 50 | 51 | func (s *SubService) getClientInfo(c *model.Client) string { 52 | now := time.Now().Unix() 53 | 54 | var result []string 55 | if vol := c.Volume - (c.Up + c.Down); vol > 0 { 56 | result = append(result, fmt.Sprintf("%s%s", s.formatTraffic(vol), "📊")) 57 | } 58 | if c.Expiry > 0 { 59 | result = append(result, fmt.Sprintf("%d%s⏳", (c.Expiry-now)/86400, "Days")) 60 | } 61 | if len(result) > 0 { 62 | return " " + strings.Join(result, " ") 63 | } else { 64 | return " ♾" 65 | } 66 | } 67 | 68 | func (s *SubService) formatTraffic(trafficBytes int64) string { 69 | if trafficBytes < 1024 { 70 | return fmt.Sprintf("%.2fB", float64(trafficBytes)/float64(1)) 71 | } else if trafficBytes < (1024 * 1024) { 72 | return fmt.Sprintf("%.2fKB", float64(trafficBytes)/float64(1024)) 73 | } else if trafficBytes < (1024 * 1024 * 1024) { 74 | return fmt.Sprintf("%.2fMB", float64(trafficBytes)/float64(1024*1024)) 75 | } else if trafficBytes < (1024 * 1024 * 1024 * 1024) { 76 | return fmt.Sprintf("%.2fGB", float64(trafficBytes)/float64(1024*1024*1024)) 77 | } else if trafficBytes < (1024 * 1024 * 1024 * 1024 * 1024) { 78 | return fmt.Sprintf("%.2fTB", float64(trafficBytes)/float64(1024*1024*1024*1024)) 79 | } else { 80 | return fmt.Sprintf("%.2fEB", float64(trafficBytes)/float64(1024*1024*1024*1024*1024)) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /backend/util/base64.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "encoding/base64" 4 | 5 | // Function to return decoded bytes if a string is Base64 encoded 6 | func StrOrBase64Encoded(str string) string { 7 | decoded, err := base64.StdEncoding.DecodeString(str) 8 | if err == nil { 9 | return string(decoded) 10 | } 11 | return str 12 | } 13 | 14 | func B64StrToByte(str string) ([]byte, error) { 15 | return base64.StdEncoding.DecodeString(str) 16 | } 17 | 18 | func ByteToB64Str(b []byte) string { 19 | return base64.StdEncoding.EncodeToString(b) 20 | } 21 | -------------------------------------------------------------------------------- /backend/util/common/err.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "s-ui/logger" 7 | ) 8 | 9 | func NewErrorf(format string, a ...interface{}) error { 10 | msg := fmt.Sprintf(format, a...) 11 | return errors.New(msg) 12 | } 13 | 14 | func NewError(a ...interface{}) error { 15 | msg := fmt.Sprintln(a...) 16 | return errors.New(msg) 17 | } 18 | 19 | func Recover(msg string) interface{} { 20 | panicErr := recover() 21 | if panicErr != nil { 22 | if msg != "" { 23 | logger.Error(msg, "panic:", panicErr) 24 | } 25 | } 26 | return panicErr 27 | } 28 | -------------------------------------------------------------------------------- /backend/util/common/random.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | var ( 9 | allSeq []rune 10 | rnd = rand.New(rand.NewSource(time.Now().UnixNano())) 11 | ) 12 | 13 | func init() { 14 | chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 15 | for _, char := range chars { 16 | allSeq = append(allSeq, char) 17 | } 18 | } 19 | 20 | func Random(n int) string { 21 | runes := make([]rune, n) 22 | for i := 0; i < n; i++ { 23 | runes[i] = allSeq[rnd.Intn(len(allSeq))] 24 | } 25 | return string(runes) 26 | } 27 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd frontend 4 | npm run build 5 | 6 | cd .. 7 | cd backend 8 | echo "Backend" 9 | 10 | rm -fr web/html/* 11 | cp -R ../frontend/dist/ web/html/ 12 | 13 | go build -o ../sui main.go -------------------------------------------------------------------------------- /core/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS singbox-builder 2 | LABEL maintainer="Alireza " 3 | WORKDIR /app 4 | ARG TARGETOS TARGETARCH 5 | ARG SINGBOX_VER=v1.9.3 6 | ARG SINGBOX_TAGS="with_quic,with_grpc,with_wireguard,with_ech,with_utls,with_reality_server,with_acme,with_v2ray_api,with_clash_api,with_gvisor" 7 | ARG GOPROXY="" 8 | ENV GOPROXY ${GOPROXY} 9 | ENV CGO_ENABLED=0 10 | ENV GOOS=$TARGETOS 11 | ENV GOARCH=$TARGETARCH 12 | RUN apk --no-cache --update add build-base gcc wget unzip git 13 | RUN set -ex \ 14 | && git clone --depth 1 --branch $SINGBOX_VER https://github.com/SagerNet/sing-box.git \ 15 | && cd sing-box \ 16 | && go build -v -trimpath -tags \ 17 | $SINGBOX_TAGS \ 18 | -ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$SINGBOX_VER\" -s -w -buildid=" \ 19 | ./cmd/sing-box 20 | 21 | FROM --platform=$TARGETPLATFORM alpine 22 | LABEL maintainer="Alireza " 23 | ENV TZ=Asia/Tehran 24 | WORKDIR /app 25 | RUN apk add --no-cache --update ca-certificates tzdata bash 26 | COPY --from=singbox-builder /app/sing-box/sing-box . 27 | COPY runSingbox.sh . 28 | ENTRYPOINT [ "./runSingbox.sh" ] -------------------------------------------------------------------------------- /core/runSingbox.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | tokill=$$ 6 | 7 | runSingbox(){ 8 | ./sing-box run & 9 | tokill=$! 10 | } 11 | 12 | terminateSingbox() 13 | { 14 | if kill -0 $tokill > /dev/null 2>&1; then 15 | echo "Terminating singbox PID=$tokill" 16 | kill $tokill 17 | while kill -0 $tokill > /dev/null 2>&1; do 18 | sleep 1 19 | done 20 | fi 21 | } 22 | 23 | reloadSingbox() 24 | { 25 | if kill -0 $tokill > /dev/null 2>&1; then 26 | kill -HUP $tokill 27 | else 28 | runSingbox 29 | fi 30 | } 31 | 32 | trap terminateSingbox SIGINT SIGTERM SIGKILL 33 | trap reloadSingbox SIGHUP 34 | 35 | runSingbox 36 | 37 | while true 38 | do 39 | sleep 5 40 | if [ -f "signal" ]; then 41 | signal=`cat signal` 42 | echo "Signal received: $signal" 43 | # Remove singnal file 44 | rm -f signal >> /dev/null 2>&1 45 | case ${signal} in 46 | "stop") 47 | terminateSingbox 48 | ;; 49 | "restart") 50 | reloadSingbox 51 | ;; 52 | esac 53 | fi 54 | 55 | # Check if sin-box crashed 56 | if ! kill -0 $tokill > /dev/null 2>&1; then 57 | if [ "$signal" != "stop" ]; then 58 | echo "Sing-Box with PID $tokill crashed. Breaking the loop..." 59 | exit 1 60 | fi 61 | fi 62 | done -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | s-ui: 4 | image: alireza7/s-ui 5 | container_name: s-ui 6 | hostname: "S-UI docker" 7 | volumes: 8 | - "singbox:/app/bin" 9 | - "$PWD/db:/app/db" 10 | - "$PWD/cert:/app/cert" 11 | environment: 12 | SINGBOX_API: "sing-box:1080" 13 | SUI_DB_FOLDER: "db" 14 | tty: true 15 | restart: unless-stopped 16 | ports: 17 | - "2095:2095" 18 | - "2096:2096" 19 | networks: 20 | - s-ui 21 | entrypoint: "./sui migrate && ./sui" 22 | 23 | sing-box: 24 | image: alireza7/s-ui-singbox 25 | container_name: sing-box 26 | volumes: 27 | - "singbox:/app/" 28 | - "$PWD/cert:/cert" 29 | networks: 30 | - s-ui 31 | ports: 32 | - "443:443" 33 | - "1443:1443" 34 | - "2443:2443" 35 | - "3443:3443" 36 | restart: unless-stopped 37 | depends_on: 38 | - s-ui 39 | 40 | networks: 41 | s-ui: 42 | driver: bridge 43 | 44 | volumes: 45 | singbox: -------------------------------------------------------------------------------- /frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript', 10 | ], 11 | rules: { 12 | 'vue/multi-word-component-names': 'off', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /bin 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # base 2 | 3 | ## Project setup 4 | 5 | ``` 6 | # yarn 7 | yarn 8 | 9 | # npm 10 | npm install 11 | 12 | # pnpm 13 | pnpm install 14 | 15 | # bun 16 | bun install 17 | ``` 18 | 19 | ### Compiles and hot-reloads for development 20 | 21 | ``` 22 | # yarn 23 | yarn dev 24 | 25 | # npm 26 | npm run dev 27 | 28 | # pnpm 29 | pnpm dev 30 | 31 | # bun 32 | pnpm run dev 33 | ``` 34 | 35 | ### Compiles and minifies for production 36 | 37 | ``` 38 | # yarn 39 | yarn build 40 | 41 | # npm 42 | npm run build 43 | 44 | # pnpm 45 | pnpm build 46 | 47 | # bun 48 | pnpm run build 49 | ``` 50 | 51 | ### Lints and fixes files 52 | 53 | ``` 54 | # yarn 55 | yarn lint 56 | 57 | # npm 58 | npm run lint 59 | 60 | # pnpm 61 | pnpm lint 62 | 63 | # bun 64 | pnpm run lint 65 | ``` 66 | 67 | ### Customize configuration 68 | 69 | See [Configuration Reference](https://vitejs.dev/config/). 70 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | S-UI 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite --host", 7 | "build": "vue-tsc --noEmit && vite build", 8 | "preview": "vite preview", 9 | "lint": "eslint . --fix --ignore-path .gitignore" 10 | }, 11 | "dependencies": { 12 | "@mdi/font": "7.4.47", 13 | "axios": "^1.7.2", 14 | "chart.js": "^4.4.3", 15 | "clipboard": "^2.0.11", 16 | "core-js": "^3.37.1", 17 | "moment": "^2.30.1", 18 | "notivue": "^2.4.4", 19 | "pinia": "^2.1.7", 20 | "qrcode.vue": "^3.4.1", 21 | "roboto-fontface": "^0.10.0", 22 | "vue": "^3.4.31", 23 | "vue-chartjs": "^5.3.1", 24 | "vue-i18n": "^9.13.1", 25 | "vue-router": "^4.4.0", 26 | "vue3-persian-datetime-picker": "^1.2.2", 27 | "vuetify": "^3.6.10" 28 | }, 29 | "devDependencies": { 30 | "@babel/types": "^7.24.7", 31 | "@types/node": "^20.14.9", 32 | "@vitejs/plugin-vue": "^5.0.5", 33 | "eslint-plugin-vue": "^9.26.0", 34 | "material-design-icons-iconfont": "^6.7.0", 35 | "sass": "^1.77.6", 36 | "typescript": "^5.5.2", 37 | "unplugin-fonts": "^1.1.1", 38 | "vite": "^5.3.2", 39 | "vite-plugin-vuetify": "^2.0.3", 40 | "vue-tsc": "^2.0.22" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolnnek/s-ui/6222533594643d7983c69c676a298a96ae1383ef/frontend/public/assets/favicon.ico -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 28 | 29 | -------------------------------------------------------------------------------- /frontend/src/assets/Vazirmatn-UI-NL-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolnnek/s-ui/6222533594643d7983c69c676a298a96ae1383ef/frontend/src/assets/Vazirmatn-UI-NL-Regular.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolnnek/s-ui/6222533594643d7983c69c676a298a96ae1383ef/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /frontend/src/components/Addr.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | -------------------------------------------------------------------------------- /frontend/src/components/DateTime.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 112 | 113 | -------------------------------------------------------------------------------- /frontend/src/components/Headers.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | -------------------------------------------------------------------------------- /frontend/src/components/Multiplex.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | -------------------------------------------------------------------------------- /frontend/src/components/Network.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/components/OutJson.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | -------------------------------------------------------------------------------- /frontend/src/components/Transport.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/src/components/UoT.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/components/Users.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/components/WgPeer.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | -------------------------------------------------------------------------------- /frontend/src/components/message.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | 33 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Direct.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Http.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Hysteria2.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Naive.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/OutShadowTls.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Selector.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Shadowsocks.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Socks.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/TProxy.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Tor.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Trojan.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Tuic.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/UrlTest.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Vless.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | -------------------------------------------------------------------------------- /frontend/src/components/protocols/Vmess.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | -------------------------------------------------------------------------------- /frontend/src/components/tiles/Gauge.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 56 | 57 | 111 | -------------------------------------------------------------------------------- /frontend/src/components/transports/Http.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | -------------------------------------------------------------------------------- /frontend/src/components/transports/HttpUpgrade.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/components/transports/WebSocket.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | -------------------------------------------------------------------------------- /frontend/src/components/transports/gRPC.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | -------------------------------------------------------------------------------- /frontend/src/layouts/default/AppBar.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 46 | -------------------------------------------------------------------------------- /frontend/src/layouts/default/Default.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 28 | 29 | -------------------------------------------------------------------------------- /frontend/src/layouts/default/Drawer.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | -------------------------------------------------------------------------------- /frontend/src/layouts/default/View.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/layouts/modals/Admin.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | -------------------------------------------------------------------------------- /frontend/src/layouts/modals/Changes.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | -------------------------------------------------------------------------------- /frontend/src/layouts/modals/Logs.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | -------------------------------------------------------------------------------- /frontend/src/layouts/modals/QrCode.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | -------------------------------------------------------------------------------- /frontend/src/layouts/modals/Ruleset.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | -------------------------------------------------------------------------------- /frontend/src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n' 2 | import en from './en' 3 | import fa from './fa' 4 | import vi from './vi' 5 | import zhcn from './zhcn' 6 | import zhtw from './zhtw' 7 | 8 | export const i18n = createI18n({ 9 | legacy: false, 10 | locale: localStorage.getItem("locale") ?? 'en', 11 | fallbackLocale: 'en', 12 | messages: { 13 | en: en, 14 | fa: fa, 15 | vi: vi, 16 | zhHans: zhcn, 17 | zhHant: zhtw 18 | }, 19 | }) 20 | 21 | export const languages = [ 22 | { title: 'English', value: 'en' }, 23 | { title: 'فارسی', value: 'fa' }, 24 | { title: 'Tiếng Việt', value: 'vi' }, 25 | { title: '简体中文', value: 'zhHans' }, 26 | { title: '繁體中文', value: 'zhHant' }, 27 | ] 28 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * main.ts 3 | * 4 | * Bootstraps Vuetify and other plugins then mounts the App` 5 | */ 6 | 7 | // Composables 8 | import { createApp, ref } from 'vue' 9 | 10 | // Components 11 | import App from './App.vue' 12 | 13 | // Use router 14 | import router from './router' 15 | 16 | // Store 17 | import store from './store' 18 | 19 | // Plugins 20 | import { registerPlugins } from '@/plugins' 21 | 22 | // Locale 23 | import { i18n } from '@/locales' 24 | import Vue3PersianDatetimePicker from 'vue3-persian-datetime-picker' 25 | 26 | // Notivue 27 | import { createNotivue } from 'notivue' 28 | import 'notivue/notification.css' 29 | import 'notivue/animations.css' 30 | const notivue = createNotivue({ 31 | position: 'bottom-center', 32 | limit: 4, 33 | enqueue: false, 34 | avoidDuplicates: true, 35 | notifications: { 36 | global: { 37 | duration: 3000 38 | } 39 | }, 40 | }) 41 | 42 | const loading = ref(false) 43 | 44 | const app = createApp(App) 45 | app.provide('loading', loading) 46 | 47 | registerPlugins(app) 48 | 49 | app 50 | .use(router) 51 | .use(store) 52 | .use(i18n) 53 | .use(notivue) 54 | .component('DatePicker', Vue3PersianDatetimePicker) 55 | .mount('#app') 56 | -------------------------------------------------------------------------------- /frontend/src/plugins/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8' 4 | axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' 5 | 6 | axios.defaults.baseURL = "./" 7 | const pendingRequests = new Map() 8 | 9 | axios.interceptors.request.use( 10 | (config) => { 11 | // Generate a unique key for the request 12 | const requestKey = `${config.method}:${config.url}` 13 | 14 | // Check if there is already a pending request with the same key 15 | if (pendingRequests.has(requestKey)) { 16 | const cancelSource = pendingRequests.get(requestKey) 17 | cancelSource.cancel('Duplicate request cancelled') 18 | } 19 | 20 | // Create a new cancel token for the request 21 | const cancelSource = axios.CancelToken.source() 22 | config.cancelToken = cancelSource.token 23 | 24 | // Store the cancel token in the pending requests map 25 | pendingRequests.set(requestKey, cancelSource) 26 | 27 | if (config.data instanceof FormData) { 28 | config.headers['Content-Type'] = 'multipart/form-data' 29 | } 30 | return config 31 | }, 32 | (error) => Promise.reject(error), 33 | ) 34 | 35 | axios.interceptors.response.use( 36 | (response) => { 37 | // Remove the request from the pending requests map 38 | const requestKey = `${response.config.method}:${response.config.url}` 39 | pendingRequests.delete(requestKey) 40 | return response 41 | }, 42 | (error) => { 43 | if (axios.isCancel(error)) { 44 | // Handle duplicate request cancellation here if needed 45 | console.warn(error.message) 46 | } else { 47 | // Remove the request from the pending requests map on error 48 | const requestKey = `${error.config.method}:${error.config.url}` 49 | pendingRequests.delete(requestKey) 50 | } 51 | return Promise.reject(error) 52 | } 53 | ); 54 | 55 | const api = axios.create() 56 | 57 | export default api 58 | -------------------------------------------------------------------------------- /frontend/src/plugins/httputil.ts: -------------------------------------------------------------------------------- 1 | import api from './api' 2 | import { i18n } from '@/locales' 3 | import router from '@/router' 4 | import { push } from 'notivue' 5 | 6 | export interface Msg { 7 | success: boolean 8 | msg: string 9 | obj: any | null 10 | } 11 | 12 | function _handleMsg(msg: any): void { 13 | if (!isMsg(msg)) { 14 | return 15 | } 16 | if(msg.msg){ 17 | if (!msg.success && msg.msg == "Invalid login") { 18 | push.error({ 19 | title: i18n.global.t('invalidLogin'), 20 | }) 21 | logout() 22 | return 23 | } 24 | if (msg.success) { 25 | push.success({ 26 | message: i18n.global.t('success') + ": " + i18n.global.t('actions.' + msg.msg), 27 | }) 28 | } else { 29 | push.error({ 30 | title: i18n.global.t('failed'), 31 | message: msg.msg 32 | }) 33 | } 34 | } 35 | } 36 | 37 | export const logout = async () => { 38 | const response = await HttpUtils.get('api/logout') 39 | if(response.success){ 40 | router.push('/login') 41 | } 42 | } 43 | 44 | function _respToMsg(resp: any): Msg { 45 | const data = resp.data 46 | if (data == null) { 47 | return { success: true, msg: "", obj: null } 48 | } else if (isMsg(data)) { 49 | if (data.hasOwnProperty('success')) { 50 | return { success: data.success, msg: data.msg, obj: data.obj || null } 51 | } else { 52 | return data 53 | } 54 | } else { 55 | return { success: false, msg: `unknown data: ${data}`, obj: null } 56 | } 57 | } 58 | 59 | function isMsg(obj: any): obj is Msg { 60 | return 'success' in obj && 'msg' in obj && 'obj' in obj 61 | } 62 | 63 | const HttpUtils = { 64 | async get(url: string, data: object = {}, options: any[] = []): Promise { 65 | let msg: Msg 66 | try { 67 | const resp = await api.get(url, { params: data, ...options }) 68 | msg = _respToMsg(resp) 69 | } catch (e: any) { 70 | msg = { success: false, msg: e.toString(), obj: null } 71 | } 72 | _handleMsg(msg) 73 | return msg 74 | }, 75 | async post(url: string, data: object | null, options: any = undefined): Promise { 76 | let msg: Msg 77 | try { 78 | const resp = await api.post(url, data, options) 79 | msg = _respToMsg(resp) 80 | } catch (e: any) { 81 | msg = { success: false, msg: e.toString(), obj: null } 82 | } 83 | _handleMsg(msg) 84 | return msg 85 | }, 86 | } 87 | 88 | export default HttpUtils; -------------------------------------------------------------------------------- /frontend/src/plugins/inData.ts: -------------------------------------------------------------------------------- 1 | export interface Addr { 2 | server: string 3 | server_port: number 4 | tls?: boolean 5 | insecure?: boolean 6 | server_name?: string 7 | remark?: string 8 | } 9 | 10 | export interface InData { 11 | id: number 12 | tag: string 13 | addrs: Addr[] 14 | outJson: any 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | // Plugins 2 | import vuetify from './vuetify' 3 | 4 | // Types 5 | import type { App } from 'vue' 6 | 7 | export function registerPlugins (app: App) { 8 | app 9 | .use(vuetify) 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/plugins/outJson.ts: -------------------------------------------------------------------------------- 1 | import { Hysteria, Hysteria2, Inbound, InTypes, Shadowsocks, Trojan, TUIC, VLESS, VMess, ShadowTLS } from "@/types/inbounds" 2 | import { iTls } from "@/types/inTls" 3 | import { oTls } from "@/types/outTls" 4 | 5 | export function fillData(out: any, inbound: Inbound, tlsClient: any) { 6 | if (Object.hasOwn(inbound, 'tls')) { 7 | const inb = inbound 8 | addTls(out,inb.tls,tlsClient) 9 | } else { 10 | delete out.tls 11 | } 12 | out.type = inbound.type 13 | out.tag = inbound.tag 14 | out.server = location.hostname 15 | out.server_port = inbound.listen_port 16 | switch(inbound.type){ 17 | case InTypes.HTTP || InTypes.SOCKS: 18 | return 19 | case InTypes.Shadowsocks: 20 | shadowsocksOut(out, inbound) 21 | return 22 | case InTypes.ShadowTLS: 23 | shadowTlsOut(out, inbound) 24 | return 25 | case InTypes.Hysteria: 26 | hysteriaOut(out, inbound) 27 | return 28 | case InTypes.Hysteria2: 29 | hysteria2Out(out, inbound) 30 | return 31 | case InTypes.TUIC: 32 | tuicOut(out, inbound) 33 | return 34 | case InTypes.VLESS: 35 | vlessOut(out, inbound) 36 | return 37 | case InTypes.Trojan: 38 | trojanOut(out, inbound) 39 | return 40 | case InTypes.VMess: 41 | vmessOut(out, inbound) 42 | return 43 | } 44 | Object.keys(out).forEach(key => delete out[key]) 45 | } 46 | 47 | function addTls(out: any, tls: iTls, tlsClient: oTls){ 48 | out.tls = tlsClient 49 | if(tls.enabled) out.tls.enabled = tls.enabled 50 | if(tls.server_name) out.tls.server_name = tls.server_name 51 | if(tls.alpn) out.tls.alpn = tls.alpn 52 | if(tls.min_version) out.tls.min_version = tls.min_version 53 | if(tls.max_version) out.tls.max_version = tls.max_version 54 | if(tls.cipher_suites) out.tls.cipher_suites = tls.cipher_suites 55 | } 56 | 57 | function shadowsocksOut(out: any, inbound: Shadowsocks) { 58 | out.method = inbound.method 59 | out.multiplex = inbound.multiplex 60 | } 61 | 62 | function shadowTlsOut(out: any, inbound: ShadowTLS) { 63 | if (inbound.version == 3) { 64 | out.version = 3 65 | } else { 66 | Object.keys(out).forEach(key => delete out[key]) 67 | } 68 | out.tls = { enabled: true } 69 | } 70 | 71 | function hysteriaOut(out: any, inbound: Hysteria) { 72 | out.up_mbps = inbound.down_mbps 73 | out.down_mbps = inbound.up_mbps 74 | out.obfs = inbound.obfs 75 | out.recv_window_conn = inbound.recv_window_conn 76 | out.disable_mtu_discovery = inbound.disable_mtu_discovery 77 | } 78 | 79 | function hysteria2Out(out: any, inbound: Hysteria2) { 80 | out.up_mbps = inbound.down_mbps 81 | out.down_mbps = inbound.up_mbps 82 | out.obfs = inbound.obfs 83 | } 84 | 85 | function tuicOut(out: any, inbound: TUIC) { 86 | out.congestion_control = inbound.congestion_control?? "cubic" 87 | out.zero_rtt_handshake = inbound.zero_rtt_handshake 88 | out.heartbeat = inbound.heartbeat 89 | } 90 | 91 | function vlessOut(out: any, inbound: VLESS) { 92 | out.multiplex = inbound.multiplex 93 | out.transport = inbound.transport 94 | } 95 | 96 | function trojanOut(out: any, inbound: Trojan) { 97 | out.multiplex = inbound.multiplex 98 | out.transport = inbound.transport 99 | } 100 | 101 | function vmessOut(out: any, inbound: VMess) { 102 | out.multiplex = inbound.multiplex 103 | out.transport = inbound.transport 104 | } 105 | -------------------------------------------------------------------------------- /frontend/src/plugins/randomUtil.ts: -------------------------------------------------------------------------------- 1 | const seq = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') 2 | 3 | const RandomUtil = { 4 | randomIntRange(min: number, max: number): number { 5 | return parseInt((Math.random() * (max - min) + min).toString(), 10) 6 | }, 7 | randomInt(n: number) { 8 | return this.randomIntRange(0, n) 9 | }, 10 | randomSeq(count: number): string { 11 | let str = '' 12 | for (let i = 0; i < count; ++i) { 13 | str += seq[this.randomInt(62)] 14 | } 15 | return str 16 | }, 17 | randomLowerAndNum(count: number): string { 18 | let str = '' 19 | for (let i = 0; i < count; ++i) { 20 | str += seq[this.randomInt(36)] 21 | } 22 | return str 23 | }, 24 | randomUUID(): string { 25 | let d = new Date().getTime() 26 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 27 | let r = (d + Math.random() * 16) % 16 | 0 28 | d = Math.floor(d / 16) 29 | return (c === 'x' ? r : (r & 0x7 | 0x8)).toString(16) 30 | }) 31 | }, 32 | randomShadowsocksPassword(n: number): string { 33 | const array = new Uint8Array(n) 34 | window.crypto.getRandomValues(array) 35 | return btoa(String.fromCharCode(...array)) 36 | }, 37 | randomShortId(): string[] { 38 | let shortIds = new Array(24).fill('') 39 | for (var ii = 0; ii < 24; ii++) { 40 | for (var jj = 0; jj < this.randomInt(8); jj++){ 41 | let randomNum = this.randomInt(256) 42 | shortIds[ii] += ('0' + randomNum.toString(16)).slice(-2) 43 | } 44 | } 45 | return shortIds 46 | } 47 | } 48 | 49 | export default RandomUtil -------------------------------------------------------------------------------- /frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/vuetify.ts 3 | * 4 | * Framework documentation: https://vuetifyjs.com` 5 | */ 6 | 7 | // Styles 8 | import '@mdi/font/css/materialdesignicons.css' 9 | import 'vuetify/styles' 10 | 11 | import colors from 'vuetify/util/colors' 12 | import { fa, en, vi, zhHans, zhHant } from 'vuetify/locale' 13 | 14 | // Composables 15 | import { createVuetify } from 'vuetify' 16 | 17 | // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides 18 | export default createVuetify({ 19 | defaults: { 20 | VRow: { dense: true } // Apply dense to v-row as default 21 | }, 22 | theme: { 23 | defaultTheme: localStorage.getItem('theme') ?? 'light', 24 | themes: { 25 | light: { 26 | colors: { 27 | primary: '#1867C0', 28 | secondary: '#5CBBF6', 29 | tertiary: '#E57373', 30 | accent: '#005CAF', 31 | error: colors.red.accent3, 32 | warning: colors.amber.base, 33 | info: colors.teal.darken1, 34 | success: colors.green.base, 35 | background: colors.grey.lighten4, 36 | }, 37 | }, 38 | dark: { 39 | colors: { 40 | primary: colors.blue.darken4, 41 | secondary: colors.grey.darken3, 42 | accent: colors.pink.darken3, 43 | error: colors.red.accent3, 44 | warning: colors.amber.darken3, 45 | info: colors.teal.lighten1, 46 | success: colors.green.darken2, 47 | surface: colors.grey.darken3, 48 | background: colors.grey.darken4, 49 | }, 50 | }, 51 | }, 52 | }, 53 | locale: { 54 | locale: localStorage.getItem("locale") ?? 'en', 55 | fallback: 'en', 56 | messages: { en, fa, vi, zhHans, zhHant }, 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /frontend/src/router/index.ts: -------------------------------------------------------------------------------- 1 | // Composables 2 | import { createRouter, createWebHistory } from 'vue-router' 3 | import Login from '@/views/Login.vue' 4 | import Data from '@/store/modules/data' 5 | 6 | const routes = [ 7 | { 8 | path: '/login', 9 | name: 'pages.login', 10 | component: Login, 11 | }, 12 | { 13 | path: '/', 14 | component: () => import('@/layouts/default/Default.vue'), 15 | meta: { requiresAuth: true }, 16 | children: [ 17 | { 18 | path: '/', 19 | name: 'pages.home', 20 | component: () => import('@/views/Home.vue'), 21 | }, 22 | { 23 | path: '/inbounds', 24 | name: 'pages.inbounds', 25 | component: () => import('@/views/Inbounds.vue'), 26 | }, 27 | { 28 | path: '/clients', 29 | name: 'pages.clients', 30 | component: () => import('@/views/Clients.vue'), 31 | }, 32 | { 33 | path: '/outbounds', 34 | name: 'pages.outbounds', 35 | component: () => import('@/views/Outbounds.vue'), 36 | }, 37 | { 38 | path: '/rules', 39 | name: 'pages.rules', 40 | component: () => import('@/views/Rules.vue'), 41 | }, 42 | { 43 | path: '/tls', 44 | name: 'pages.tls', 45 | component: () => import('@/views/Tls.vue'), 46 | }, 47 | { 48 | path: '/basics', 49 | name: 'pages.basics', 50 | component: () => import('@/views/Basics.vue'), 51 | }, 52 | { 53 | path: '/admins', 54 | name: 'pages.admins', 55 | component: () => import('@/views/Admins.vue'), 56 | }, 57 | { 58 | path: '/settings', 59 | name: 'pages.settings', 60 | component: () => import('@/views/Settings.vue'), 61 | }, 62 | ], 63 | }, 64 | ] 65 | 66 | const router = createRouter({ 67 | history: createWebHistory((window as any).BASE_URL), 68 | routes, 69 | }) 70 | 71 | const DEFAULT_TITLE = 'S-UI' 72 | let intervalId:any 73 | 74 | // Navigation guard to check authentication state 75 | router.beforeEach((to, from, next) => { 76 | // Check the session cookie 77 | const sessionCookie = document.cookie.split(';').find(cookie => cookie.trim().startsWith('s-ui=')) 78 | const isAuthenticated = !!sessionCookie 79 | 80 | // If the route requires authentication and the user is not authenticated, redirect to /login 81 | if (to.meta.requiresAuth && !isAuthenticated) { 82 | next('/login') 83 | } else if (to.path === '/login' && isAuthenticated) { 84 | // If already authenticated and visiting /route, redirect to '/' 85 | next('/') 86 | } else { 87 | // Load default data 88 | if(to.path != '/login'){ 89 | loadDataInterval() 90 | } else { 91 | if (intervalId) { 92 | clearInterval(intervalId) 93 | intervalId = undefined 94 | } 95 | } 96 | next() 97 | } 98 | }) 99 | 100 | const loadDataInterval = () => { 101 | if (intervalId) return 102 | Data().loadData() 103 | intervalId = setInterval(() => { 104 | Data().loadData() 105 | }, 10000) 106 | } 107 | 108 | export default router 109 | -------------------------------------------------------------------------------- /frontend/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | 3 | const pinia = createPinia() 4 | 5 | export default pinia -------------------------------------------------------------------------------- /frontend/src/styles/settings.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * src/styles/settings.scss 3 | * 4 | * Configures SASS variables and Vuetify overwrites 5 | */ 6 | 7 | // https://vuetifyjs.com/features/sass-variables/` 8 | // @use 'vuetify/settings' with ( 9 | // $color-pack: false 10 | // ); 11 | @font-face { 12 | font-display: swap; 13 | font-family: 'Vazirmatn'; 14 | font-style: normal; 15 | font-weight: 400; 16 | src: url('@/assets/Vazirmatn-UI-NL-Regular.woff2') format('woff2'); 17 | unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC, U+0030-0039; 18 | } 19 | $body-font-family: "Vazirmatn"; 20 | 21 | $typoOptions: text-h1, text-sm-h1, text-md-h1, text-lg-h1, text-h2, text-sm-h2, 22 | text-md-h2, text-lg-h2, text-h3, text-sm-h3, text-md-h3, text-lg-h3, text-h4, 23 | text-sm-h4, text-md-h4, text-lg-h4, text-h5, text-sm-h5, text-md-h5, 24 | text-lg-h5, text-h6, text-sm-h6, text-md-h6, text-lg-h6, headline, title, 25 | subtitle-1, subtitle-2, text-body-1, text-sm-body-1, text-md-body-1, 26 | text-lg-body-1, text-body-2, text-sm-body-2, text-md-body-2, text-lg-body-2, 27 | text-caption; 28 | body { 29 | font-family: -apple-system, BlinkMacSystemFont, $body-font-family, sans-serif !important; 30 | @each $typoOption in $typoOptions { 31 | .#{$typoOption} { 32 | font-family: -apple-system, BlinkMacSystemFont, $body-font-family, sans-serif !important; 33 | } 34 | } 35 | } 36 | 37 | .v-btn { 38 | letter-spacing: 0; 39 | } -------------------------------------------------------------------------------- /frontend/src/types/brutal.ts: -------------------------------------------------------------------------------- 1 | export interface Brutal { 2 | enabled: boolean 3 | up_mbps: number 4 | down_mbps: number 5 | } -------------------------------------------------------------------------------- /frontend/src/types/clients.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "@/plugins/link" 2 | import RandomUtil from "@/plugins/randomUtil" 3 | 4 | export interface Client { 5 | id?: number 6 | enable: boolean 7 | name: string 8 | config: Config 9 | inbounds: string[] 10 | links: Link[] 11 | volume: number 12 | expiry: number 13 | up: number 14 | down: number 15 | desc: string 16 | } 17 | 18 | const defaultClient: Client = { 19 | enable: true, 20 | name: "", 21 | config: {}, 22 | inbounds: [], 23 | links: [], 24 | volume: 0, 25 | expiry: 0, 26 | up: 0, 27 | down: 0, 28 | desc: "", 29 | } 30 | 31 | type Config = { 32 | [key: string]: { 33 | name?: string 34 | username?: string 35 | [key: string]: any 36 | } 37 | } 38 | 39 | export function updateConfigs(configs: Config, newUserName: string): Config { 40 | 41 | for (const key in configs) { 42 | if (configs.hasOwnProperty(key)) { 43 | const config = configs[key] 44 | if (config.hasOwnProperty("name")) { 45 | config.name = newUserName 46 | } else if (config.hasOwnProperty("username")) { 47 | config.username = newUserName 48 | } 49 | } 50 | } 51 | 52 | return configs 53 | } 54 | 55 | export function randomConfigs(user: string): Config { 56 | const mixedPassword = RandomUtil.randomSeq(10) 57 | const ssPassword = RandomUtil.randomShadowsocksPassword(32) 58 | const uuid = RandomUtil.randomUUID() 59 | return { 60 | mixed: { 61 | username: user, 62 | password: mixedPassword, 63 | }, 64 | socks: { 65 | username: user, 66 | password: mixedPassword, 67 | }, 68 | http: { 69 | username: user, 70 | password: mixedPassword, 71 | }, 72 | shadowsocks: { 73 | name: user, 74 | password: ssPassword, 75 | }, 76 | shadowtls: { 77 | name: user, 78 | password: ssPassword, 79 | }, 80 | vmess: { 81 | name: user, 82 | uuid: uuid, 83 | alterId: 0, 84 | }, 85 | vless: { 86 | name: user, 87 | uuid: uuid, 88 | flow: "xtls-rprx-vision", 89 | }, 90 | trojan: { 91 | name: user, 92 | password: mixedPassword, 93 | }, 94 | naive: { 95 | username: user, 96 | password: mixedPassword, 97 | }, 98 | hysteria: { 99 | name: user, 100 | auth_str: mixedPassword, 101 | }, 102 | tuic: { 103 | name: user, 104 | uuid: uuid, 105 | password: mixedPassword, 106 | }, 107 | hysteria2: { 108 | name: user, 109 | password: mixedPassword, 110 | }, 111 | } 112 | } 113 | 114 | export function createClient(json?: Partial): Client { 115 | defaultClient.name = RandomUtil.randomSeq(8) 116 | const defaultObject: Client = { ...defaultClient, ...(json || {}) } 117 | return defaultObject 118 | } 119 | -------------------------------------------------------------------------------- /frontend/src/types/config.ts: -------------------------------------------------------------------------------- 1 | import { Inbound } from './inbounds' 2 | import { Dial, Outbound } from './outbounds' 3 | 4 | interface Log { 5 | disabled?: boolean 6 | level?: string 7 | output?: string 8 | timestamp?: boolean 9 | } 10 | 11 | interface Dns { 12 | servers: DnsServer[] 13 | final?: string 14 | strategy?: string 15 | } 16 | 17 | interface DnsServer{ 18 | tag?: string, 19 | address: string, 20 | address_resolver?: string, 21 | address_strategy?: string, 22 | strategy?: string, 23 | detour?: string 24 | } 25 | 26 | export interface Ntp extends Dial{ 27 | enabled?: boolean 28 | server: string 29 | server_port?: number 30 | interval?: string 31 | } 32 | 33 | interface Route { 34 | rules: RouteRule[] | RouteRuleLogical[] 35 | rule_set: RouteRuleSet[] 36 | final?: string, 37 | auto_detect_interface?: boolean 38 | default_interface?: string 39 | default_mark?: number 40 | } 41 | 42 | interface RouteRule { 43 | inbound?: string[] | string 44 | ip_version?: 4 | 6, 45 | network?: "tcp" | "udp" 46 | auth_user?: string[] 47 | protocol?: string[] | string 48 | domain?: string[] | string 49 | domain_suffix?: string[] | string 50 | domain_keyword?: string[] | string 51 | domain_regex?: string[] | string 52 | source_ip_cidr?: string[] | string 53 | source_ip_is_private?: boolean 54 | ip_cidr?: string[] | string 55 | ip_is_private?: boolean 56 | source_port?: number[] | number 57 | source_port_range?: string[] | string 58 | port?: number[] | number 59 | port_range?: string[] | string 60 | clash_mode?: string 61 | rule_set?: string[] | string 62 | invert?: boolean 63 | outbound: string 64 | } 65 | 66 | interface RouteRuleLogical { 67 | type: "logical" 68 | mode: "and" | "or" 69 | rules: RouteRule[] 70 | invert?: boolean 71 | outbound: string 72 | } 73 | 74 | interface RouteRuleSet { 75 | type: string 76 | tag: string 77 | format: string 78 | path?: string 79 | url?: string 80 | download_detour?: string 81 | update_interval?: string 82 | } 83 | 84 | interface Experimental { 85 | cache_file?: CacheFile 86 | clash_api?: ClashApi 87 | v2ray_api: V2rayApi 88 | } 89 | 90 | interface CacheFile { 91 | enabled?: boolean 92 | path?: string 93 | cache_id?: string 94 | store_fakeip?: boolean 95 | } 96 | 97 | interface V2rayApi { 98 | listen: string 99 | stats: V2rayApiStats 100 | } 101 | 102 | export interface V2rayApiStats { 103 | enabled: boolean 104 | inbounds: string[] 105 | outbounds: string[] 106 | users: string[] 107 | } 108 | 109 | interface ClashApi { 110 | external_controller?: string 111 | external_ui?: string 112 | external_ui_download_url?: string 113 | external_ui_download_detour?: string 114 | secret?: string 115 | default_mode?: string 116 | } 117 | 118 | export interface Config { 119 | log: Log 120 | dns: Dns 121 | ntp?: Ntp 122 | inbounds: Inbound[] 123 | outbounds: Outbound[] 124 | route: Route 125 | experimental: Experimental 126 | } -------------------------------------------------------------------------------- /frontend/src/types/dial.ts: -------------------------------------------------------------------------------- 1 | export interface Dial { 2 | detour?: string 3 | bind_interface?: string 4 | inet4_bind_address?: string 5 | inet6_bind_address?:string 6 | routing_mark?: number 7 | reuse_addr?: boolean 8 | connect_timeout?: string 9 | tcp_fast_open?: boolean 10 | tcp_multi_path?: boolean 11 | udp_fragment?: boolean 12 | domain_strategy?: string 13 | fallback_delay?: string 14 | } -------------------------------------------------------------------------------- /frontend/src/types/inTls.ts: -------------------------------------------------------------------------------- 1 | import { Dial } from "./dial" 2 | 3 | export interface iTls { 4 | enabled?: boolean 5 | server_name?: string 6 | alpn?: string[] 7 | min_version?: string 8 | max_version?: string 9 | cipher_suites?: string[] 10 | certificate?: string[] 11 | certificate_path?: string 12 | key?: string[] 13 | key_path?: string 14 | acme?: acme 15 | ech?: ech 16 | reality?: reality 17 | } 18 | 19 | export interface acme { 20 | domain: string[] 21 | data_directory?: string 22 | default_server_name?: string 23 | email?: string 24 | provider?: string 25 | disable_http_challenge?: boolean 26 | disable_tls_alpn_challenge?: boolean 27 | alternative_http_port?: number 28 | alternative_tls_port?: number 29 | external_account?: { 30 | key_id: string 31 | mac_key: string 32 | } 33 | dns01_challenge?: { 34 | provider: string 35 | [key: string]: string 36 | } 37 | } 38 | 39 | export interface ech { 40 | enabled: boolean 41 | pq_signature_schemes_enabled?: boolean 42 | dynamic_record_sizing_disabled?: boolean 43 | key?: string[] 44 | key_path?: string 45 | } 46 | 47 | interface realityHanshake extends Dial { 48 | server: string 49 | server_port: number 50 | } 51 | 52 | export interface reality { 53 | enabled: boolean 54 | handshake: realityHanshake 55 | private_key: string 56 | short_id: string[] 57 | max_time_difference?: string 58 | } 59 | 60 | export const defaultInTls: iTls = { 61 | alpn: ['h3', 'h2', 'http/1.1'], 62 | min_version: "1.2", 63 | max_version: "1.3", 64 | cipher_suites: [], 65 | } -------------------------------------------------------------------------------- /frontend/src/types/multiplex.ts: -------------------------------------------------------------------------------- 1 | import { Brutal } from "./brutal" 2 | 3 | export interface iMultiplex{ 4 | enabled: boolean 5 | padding?: boolean 6 | brutal?: Brutal 7 | } 8 | 9 | export interface oMultiplex extends iMultiplex{ 10 | protocol?: "smux" | "yamux" | "h2mux" 11 | max_connections?: number 12 | min_streams?: number 13 | max_streams?: number 14 | } -------------------------------------------------------------------------------- /frontend/src/types/outTls.ts: -------------------------------------------------------------------------------- 1 | export interface oTls { 2 | enabled?: boolean 3 | disable_sni?: boolean 4 | server_name?: string 5 | insecure?: boolean 6 | alpn?: string[] 7 | min_version?: string 8 | max_version?: string 9 | cipher_suites?: string[] 10 | certificate?: string 11 | certificate_path?: string 12 | ech?: { 13 | enabled: boolean 14 | pq_signature_schemes_enabled?: boolean 15 | dynamic_record_sizing_disabled?: boolean 16 | config?: string[], 17 | config_path?: string 18 | }, 19 | utls?: { 20 | enabled: boolean 21 | fingerprint: string 22 | }, 23 | reality?: { 24 | enabled: boolean 25 | public_key: string 26 | short_id: string 27 | } 28 | } 29 | 30 | export const defaultOutTls: oTls = { 31 | alpn: ['h3', 'h2', 'http/1.1'], 32 | min_version: "1.2", 33 | max_version: "1.3", 34 | cipher_suites: [], 35 | utls: { 36 | enabled: true, 37 | fingerprint: "chrome", 38 | }, 39 | reality: { 40 | enabled: true, 41 | public_key: "", 42 | short_id: "", 43 | }, 44 | ech: { 45 | enabled: true, 46 | pq_signature_schemes_enabled: false, 47 | dynamic_record_sizing_disabled: false, 48 | config_path: "", 49 | } 50 | } -------------------------------------------------------------------------------- /frontend/src/types/rules.ts: -------------------------------------------------------------------------------- 1 | export interface logicalRule { 2 | type: 'logical' | 'simple' 3 | mode: 'and' | 'or' 4 | rules: rule[] 5 | invert: boolean 6 | outbound: string 7 | } 8 | 9 | export interface rule { 10 | inbound?: string[] 11 | ip_version?: 4 | 6 12 | network?: string[] 13 | auth_user?: string[] 14 | protocol?: string[] 15 | domain?: string[] 16 | domain_suffix?: string[] 17 | domain_keyword?: string[] 18 | domain_regex?: string[] 19 | source_ip_cidr?: string[] 20 | source_ip_is_private?: boolean 21 | ip_cidr?: string[] 22 | ip_is_private?: boolean 23 | source_port?: number[] 24 | source_port_range?: string[] 25 | port?: number[] 26 | port_range?: string[] 27 | process_name?: string[] 28 | process_path?: string[] 29 | package_name?: string[] 30 | user?: string[] 31 | user_id?: number[] 32 | clash_mode?: string 33 | rule_set?: string[] 34 | rule_set_ipcidr_match_source?: boolean 35 | invert?: boolean 36 | outbound?: string 37 | } 38 | 39 | export interface ruleset { 40 | type: 'local' | 'remote' 41 | tag: string 42 | format: 'source' | 'binary' 43 | path?: string 44 | url?: string 45 | download_detour?: string 46 | update_interval?: string 47 | } -------------------------------------------------------------------------------- /frontend/src/types/transport.ts: -------------------------------------------------------------------------------- 1 | export const TrspTypes = { 2 | HTTP: 'http', 3 | WebSocket: 'ws', 4 | QUIC: 'quic', 5 | gRPC: 'grpc', 6 | HTTPUpgrade: "httpupgrade" 7 | } 8 | 9 | export type TrspType = typeof TrspTypes[keyof typeof TrspTypes] 10 | 11 | export type Transport = HTTP|WebSocket|QUIC|gRPC|HTTPUpgrade 12 | 13 | interface TransportBasics { 14 | type: TrspType 15 | } 16 | 17 | export interface HTTP extends TransportBasics { 18 | host?: string[] 19 | path?: string 20 | method?: string 21 | headers?: {} 22 | idle_timeout?: string 23 | ping_timeout?: string 24 | } 25 | 26 | export interface WebSocket extends TransportBasics { 27 | path: string 28 | headers?: { 29 | Host: string 30 | } 31 | max_early_data?: number 32 | early_data_header_name?: string 33 | } 34 | 35 | export interface QUIC extends TransportBasics {} 36 | 37 | export interface gRPC extends TransportBasics { 38 | service_name?: string 39 | idle_timeout?: string 40 | ping_timeout?: string 41 | permit_without_stream?: boolean 42 | } 43 | 44 | export interface HTTPUpgrade extends TransportBasics { 45 | host?: string 46 | path?: string 47 | headers?: {} 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/views/Admins.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | -------------------------------------------------------------------------------- /frontend/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /frontend/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 86 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | } 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "jsx": "preserve", 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true, 15 | "noEmit": true, 16 | "paths": { 17 | "@/*": [ 18 | "src/*" 19 | ] 20 | } 21 | }, 22 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 23 | "references": [{ "path": "./tsconfig.node.json" }], 24 | "exclude": ["node_modules"], 25 | } 26 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.mts"], 9 | "exclude": [] 10 | } 11 | -------------------------------------------------------------------------------- /frontend/vite.config.mts: -------------------------------------------------------------------------------- 1 | // Plugins 2 | import vue from '@vitejs/plugin-vue' 3 | import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' 4 | 5 | // Utilities 6 | import { defineConfig } from 'vite' 7 | import { fileURLToPath, URL } from 'node:url' 8 | import { randomBytes } from 'crypto'; 9 | 10 | function getUniqueFileName(template) { 11 | if (template.includes('.js') || template.includes('.css')) { 12 | const hash = randomBytes(8).toString('hex'); 13 | return template.replace('[name]', hash); 14 | } 15 | return template; 16 | } 17 | 18 | export default defineConfig({ 19 | base: '', 20 | plugins: [ 21 | vue({ 22 | template: { transformAssetUrls }, 23 | }), 24 | vuetify({ 25 | autoImport: true, 26 | styles: { 27 | configFile: 'src/styles/settings.scss', 28 | }, 29 | }) 30 | ], 31 | build: { 32 | manifest: false, 33 | outDir: 'dist', 34 | chunkSizeWarningLimit: 1600, 35 | rollupOptions: { 36 | output: { 37 | inlineDynamicImports: true, 38 | entryFileNames: getUniqueFileName('assets/[name].js'), 39 | chunkFileNames: getUniqueFileName('assets/[name].js'), 40 | assetFileNames: (assetInfo) => { 41 | if (assetInfo.name == "index.css") return getUniqueFileName('assets/[name].css'); 42 | return 'assets/' + assetInfo.name; 43 | }, 44 | }, 45 | } 46 | }, 47 | define: { 'process.env': {} }, 48 | resolve: { 49 | alias: { 50 | '@': fileURLToPath(new URL('./src', import.meta.url)), 51 | }, 52 | extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'], 53 | }, 54 | server: { 55 | port: 3000, 56 | proxy: { 57 | '/app/api': { 58 | target: 'http://localhost:2095', 59 | changeOrigin: true, 60 | }, 61 | }, 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /runSUI.sh: -------------------------------------------------------------------------------- 1 | ./build.sh 2 | SUI_DB_FOLDER="db" SUI_DEBUG=true ./sui -------------------------------------------------------------------------------- /s-ui.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=s-ui Service 3 | After=network.target 4 | Wants=network.target 5 | 6 | [Service] 7 | Type=simple 8 | WorkingDirectory=/usr/local/s-ui/ 9 | ExecStart=/usr/local/s-ui/sui 10 | Restart=on-failure 11 | RestartSec=10s 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /sing-box.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=sing-box service 3 | Documentation=https://sing-box.sagernet.org 4 | After=network.target nss-lookup.target network-online.target 5 | 6 | [Service] 7 | CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 8 | AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 9 | WorkingDirectory=/usr/local/s-ui/bin/ 10 | ExecStart=/usr/local/s-ui/bin/runSingbox.sh 11 | ExecReload=/bin/kill -HUP $MAINPID 12 | Restart=on-failure 13 | RestartSec=10s 14 | LimitNOFILE=infinity 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | --------------------------------------------------------------------------------