├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question-template.md ├── dependabot.yml └── workflows │ ├── docker.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── apiHandler.go ├── apiService.go ├── apiV2Handler.go ├── session.go └── utils.go ├── app └── app.go ├── build.sh ├── cmd ├── admin.go ├── cmd.go ├── migration │ ├── 1_1.go │ ├── 1_2.go │ └── main.go └── setting.go ├── config ├── config.go ├── name └── version ├── core ├── box.go ├── conntracker.go ├── endpoint.go ├── log.go ├── main.go └── register.go ├── cronjob ├── checkCoreJob.go ├── cronJob.go ├── delStatsJob.go ├── depleteJob.go └── statsJob.go ├── database ├── backup.go ├── db.go └── model │ ├── endpoints.go │ ├── inbounds.go │ ├── model.go │ └── outbounds.go ├── docker-compose.yml ├── entrypoint.sh ├── go.mod ├── go.sum ├── install.sh ├── logger └── logger.go ├── main.go ├── middleware └── domainValidator.go ├── network ├── auto_https_conn.go └── auto_https_listener.go ├── package-lock.json ├── runSUI.sh ├── s-ui.service ├── s-ui.sh ├── service ├── client.go ├── config.go ├── endpoints.go ├── inbounds.go ├── outbounds.go ├── panel.go ├── server.go ├── setting.go ├── stats.go ├── tls.go ├── user.go └── warp.go ├── sub ├── jsonService.go ├── linkService.go ├── sub.go ├── subHandler.go └── subService.go ├── util ├── base64.go ├── common │ ├── err.go │ └── random.go ├── genLink.go ├── linkToJson.go └── outJson.go └── web └── web.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | release/ 4 | backup/ 5 | bin/ 6 | db/ 7 | sui 8 | web/html 9 | main 10 | tmp 11 | .sync* 12 | *.tar.gz 13 | 14 | # local env files 15 | .env.local 16 | .env.*.local 17 | 18 | # Log files 19 | *.log* 20 | .cache 21 | 22 | # Editor directories and files 23 | .idea 24 | .vscode 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: alireza7 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: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" -------------------------------------------------------------------------------- /.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-22.04 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4.2.2 15 | with: 16 | submodules: recursive 17 | 18 | - name: Docker meta 19 | id: meta 20 | uses: docker/metadata-action@v5 21 | with: 22 | images: | 23 | alireza7/s-ui 24 | ghcr.io/alireza0/s-ui 25 | tags: | 26 | type=ref,event=branch 27 | type=ref,event=tag 28 | type=pep440,pattern={{version}} 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: . 53 | push: true 54 | platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386 55 | tags: ${{ steps.meta.outputs.tags }} 56 | 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.2.2 25 | with: 26 | submodules: recursive 27 | 28 | - name: Setup Go 29 | uses: actions/setup-go@v5 30 | with: 31 | cache: false 32 | go-version-file: go.mod 33 | 34 | - name: Setup Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: '22' 38 | registry-url: 'https://registry.npmjs.org' 39 | 40 | - name: Install dependencies 41 | run: | 42 | sudo apt-get update 43 | if [ "${{ matrix.platform }}" == "arm64" ]; then 44 | sudo apt install gcc-aarch64-linux-gnu 45 | elif [ "${{ matrix.platform }}" == "armv7" ]; then 46 | sudo apt install gcc-arm-linux-gnueabihf 47 | elif [ "${{ matrix.platform }}" == "armv6" ]; then 48 | sudo apt install gcc-arm-linux-gnueabihf 49 | elif [ "${{ matrix.platform }}" == "armv5" ]; then 50 | sudo apt install gcc-arm-linux-gnueabi 51 | elif [ "${{ matrix.platform }}" == "386" ]; then 52 | sudo apt install gcc-i686-linux-gnu 53 | elif [ "${{ matrix.platform }}" == "s390x" ]; then 54 | sudo apt install gcc-s390x-linux-gnu 55 | fi 56 | 57 | - name: Build frontend 58 | run: | 59 | cd frontend 60 | npm install 61 | npm run build 62 | cd .. 63 | mv frontend/dist web/html 64 | rm -fr frontend 65 | 66 | - name: Build s-ui 67 | run: | 68 | export CGO_ENABLED=1 69 | export GOOS=linux 70 | export GOARCH=${{ matrix.platform }} 71 | if [ "${{ matrix.platform }}" == "arm64" ]; then 72 | export GOARCH=arm64 73 | export CC=aarch64-linux-gnu-gcc 74 | elif [ "${{ matrix.platform }}" == "armv7" ]; then 75 | export GOARCH=arm 76 | export GOARM=7 77 | export CC=arm-linux-gnueabihf-gcc 78 | elif [ "${{ matrix.platform }}" == "armv6" ]; then 79 | export GOARCH=arm 80 | export GOARM=6 81 | export CC=arm-linux-gnueabihf-gcc 82 | elif [ "${{ matrix.platform }}" == "armv5" ]; then 83 | export GOARCH=arm 84 | export GOARM=5 85 | export CC=arm-linux-gnueabi-gcc 86 | elif [ "${{ matrix.platform }}" == "386" ]; then 87 | export GOARCH=386 88 | export CC=i686-linux-gnu-gcc 89 | elif [ "${{ matrix.platform }}" == "s390x" ]; then 90 | export GOARCH=s390x 91 | export CC=s390x-linux-gnu-gcc 92 | fi 93 | 94 | ### Build s-ui 95 | go build -ldflags="-w -s" -tags "with_quic,with_grpc,with_ech,with_utls,with_reality_server,with_acme,with_gvisor" -o sui main.go 96 | 97 | mkdir s-ui 98 | cp sui s-ui/ 99 | cp s-ui.service s-ui/ 100 | cp s-ui.sh s-ui/ 101 | 102 | - name: Package 103 | run: tar -zcvf s-ui-linux-${{ matrix.platform }}.tar.gz s-ui 104 | 105 | - name: Upload 106 | uses: svenstaro/upload-release-action@v2 107 | with: 108 | repo_token: ${{ secrets.GITHUB_TOKEN }} 109 | tag: ${{ github.ref }} 110 | file: s-ui-linux-${{ matrix.platform }}.tar.gz 111 | asset_name: s-ui-linux-${{ matrix.platform }}.tar.gz 112 | prerelease: true 113 | overwrite: true 114 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | release/ 4 | backup/ 5 | bin/ 6 | db/ 7 | sui 8 | web/html 9 | main 10 | tmp 11 | .sync* 12 | *.tar.gz 13 | frontend/ 14 | 15 | # local env files 16 | .env.local 17 | .env.*.local 18 | 19 | # Log files 20 | *.log* 21 | .cache 22 | 23 | # Editor directories and files 24 | .idea 25 | .vscode 26 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "frontend"] 2 | path = frontend 3 | url = https://github.com/alireza0/s-ui-frontend 4 | branch = main 5 | -------------------------------------------------------------------------------- /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.23-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 . . 14 | COPY --from=front-builder /app/dist/ /app/web/html/ 15 | RUN go build -ldflags="-w -s" -tags "with_quic,with_grpc,with_ech,with_utls,with_reality_server,with_acme,with_gvisor" -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 | COPY entrypoint.sh /app/ 24 | VOLUME [ "s-ui" ] 25 | ENTRYPOINT [ "./entrypoint.sh" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S-UI 2 | **An Advanced Web Panel • Built on SagerNet/Sing-Box** 3 | 4 | ![](https://img.shields.io/github/v/release/alireza0/s-ui.svg) 5 | ![S-UI Docker pull](https://img.shields.io/docker/pulls/alireza7/s-ui.svg) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/alireza0/s-ui)](https://goreportcard.com/report/github.com/alireza0/s-ui) 7 | [![Downloads](https://img.shields.io/github/downloads/alireza0/s-ui/total.svg)](https://img.shields.io/github/downloads/alireza0/s-ui/total.svg) 8 | [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html) 9 | 10 | > **Disclaimer:** This project is only for personal learning and communication, please do not use it for illegal purposes, please do not use it in a production environment 11 | 12 | **If you think this project is helpful to you, you may wish to give a**:star2: 13 | 14 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/alireza7) 15 | 16 | - USDT (TRC20): `TYTq73Gj6dJ67qe58JVPD9zpjW2cc9XgVz` 17 | 18 | ## Quick Overview 19 | | Features | Enable? | 20 | | -------------------------------------- | :----------------: | 21 | | Multi-Protocol | :heavy_check_mark: | 22 | | Multi-Language | :heavy_check_mark: | 23 | | Multi-Client/Inbound | :heavy_check_mark: | 24 | | Advanced Traffic Routing Interface | :heavy_check_mark: | 25 | | Client & Traffic & System Status | :heavy_check_mark: | 26 | | Subscription Service (link/json + info)| :heavy_check_mark: | 27 | | Dark/Light Theme | :heavy_check_mark: | 28 | | API Interface | :heavy_check_mark: | 29 | 30 | ## API Documentation 31 | 32 | [API-Documentation Wiki](https://github.com/alireza0/s-ui/wiki/API-Documentation) 33 | 34 | ## Default Installation Information 35 | - Panel Port: 2095 36 | - Panel Path: /app/ 37 | - Subscription Port: 2096 38 | - Subscription Path: /sub/ 39 | - User/Password: admin 40 | 41 | ## Install & Upgrade to Latest Version 42 | 43 | ```sh 44 | bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/master/install.sh) 45 | ``` 46 | 47 | ## Install legacy Version 48 | 49 | **Step 1:** To install your desired legacy version, add the version to the end of the installation command. e.g., ver `1.0.0`: 50 | 51 | ```sh 52 | VERSION=1.0.0 && bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/$VERSION/install.sh) $VERSION 53 | ``` 54 | 55 | ## Manual installation 56 | 57 | 1. Get the latest version of S-UI based on your OS/Architecture from GitHub: [https://github.com/alireza0/s-ui/releases/latest](https://github.com/alireza0/s-ui/releases/latest) 58 | 2. **OPTIONAL** Get the latest version of `s-ui.sh` [https://raw.githubusercontent.com/alireza0/s-ui/master/s-ui.sh](https://raw.githubusercontent.com/alireza0/s-ui/master/s-ui.sh) 59 | 3. **OPTIONAL** Copy `s-ui.sh` to /usr/bin/ and run `chmod +x /usr/bin/s-ui`. 60 | 4. Extract s-ui tar.gz file to a directory of your choice and navigate to the directory where you extracted the tar.gz file. 61 | 5. Copy *.service files to /etc/systemd/system/ and run `systemctl daemon-reload`. 62 | 6. Enable autostart and start S-UI service using `systemctl enable s-ui --now` 63 | 7. Start sing-box service using `systemctl enable sing-box --now` 64 | 65 | ## Uninstall S-UI 66 | 67 | ```sh 68 | sudo -i 69 | 70 | systemctl disable s-ui --now 71 | 72 | rm -f /etc/systemd/system/sing-box.service 73 | systemctl daemon-reload 74 | 75 | rm -fr /usr/local/s-ui 76 | rm /usr/bin/s-ui 77 | ``` 78 | 79 | ## Install using Docker 80 | 81 |
82 | Click for details 83 | 84 | ### Usage 85 | 86 | **Step 1:** Install Docker 87 | 88 | ```shell 89 | curl -fsSL https://get.docker.com | sh 90 | ``` 91 | 92 | **Step 2:** Install S-UI 93 | 94 | > Docker compose method 95 | 96 | ```shell 97 | mkdir s-ui && cd s-ui 98 | wget -q https://raw.githubusercontent.com/alireza0/s-ui/master/docker-compose.yml 99 | docker compose up -d 100 | ``` 101 | 102 | > Use docker 103 | 104 | ```shell 105 | mkdir s-ui && cd s-ui 106 | docker run -itd \ 107 | -p 2095:2095 -p 2096:2096 -p 443:443 -p 80:80 \ 108 | -v $PWD/db/:/usr/local/s-ui/db/ \ 109 | -v $PWD/cert/:/root/cert/ \ 110 | --name s-ui --restart=unless-stopped \ 111 | alireza7/s-ui:latest 112 | ``` 113 | 114 | > Build your own image 115 | 116 | ```shell 117 | git clone https://github.com/alireza0/s-ui 118 | git submodule update --init --recursive 119 | docker build -t s-ui . 120 | ``` 121 | 122 |
123 | 124 | ## Manual run ( contribution ) 125 | 126 |
127 | Click for details 128 | 129 | ### Build and run whole project 130 | ```shell 131 | ./runSUI.sh 132 | ``` 133 | 134 | ### Clone the repository 135 | ```shell 136 | # clone repository 137 | git clone https://github.com/alireza0/s-ui 138 | # clone submodules 139 | git submodule update --init --recursive 140 | ``` 141 | 142 | 143 | ### - Frontend 144 | 145 | Visit [s-ui-frontend](https://github.com/alireza0/s-ui-frontend) for frontend code 146 | 147 | ### - Backend 148 | > Please build frontend once before! 149 | 150 | To build backend: 151 | ```shell 152 | # remove old frontend compiled files 153 | rm -fr web/html/* 154 | # apply new frontend compiled files 155 | cp -R frontend/dist/ web/html/ 156 | # build 157 | go build -o sui main.go 158 | ``` 159 | 160 | To run backend (from root folder of repository): 161 | ```shell 162 | ./sui 163 | ``` 164 | 165 |
166 | 167 | ## Languages 168 | 169 | - English 170 | - Farsi 171 | - Vietnamese 172 | - Chinese (Simplified) 173 | - Chinese (Traditional) 174 | - Russian 175 | 176 | ## Features 177 | 178 | - Supported protocols: 179 | - General: Mixed, SOCKS, HTTP, HTTPS, Direct, Redirect, TProxy 180 | - V2Ray based: VLESS, VMess, Trojan, Shadowsocks 181 | - Other protocols: ShadowTLS, Hysteria, Hysteria2, Naive, TUIC 182 | - Supports XTLS protocols 183 | - An advanced interface for routing traffic, incorporating PROXY Protocol, External, and Transparent Proxy, SSL Certificate, and Port 184 | - An advanced interface for inbound and outbound configuration 185 | - Clients’ traffic cap and expiration date 186 | - Displays online clients, inbounds and outbounds with traffic statistics, and system status monitoring 187 | - Subscription service with ability to add external links and subscription 188 | - HTTPS for secure access to the web panel and subscription service (self-provided domain + SSL certificate) 189 | - Dark/Light theme 190 | 191 | ## Recommended OS 192 | 193 | - Ubuntu 20.04+ 194 | - Debian 11+ 195 | - CentOS 8+ 196 | - Fedora 36+ 197 | - Arch Linux 198 | - Parch Linux 199 | - Manjaro 200 | - Armbian 201 | - AlmaLinux 9+ 202 | - Rocky Linux 9+ 203 | - Oracle Linux 8+ 204 | - OpenSUSE Tubleweed 205 | 206 | ## Environment Variables 207 | 208 |
209 | Click for details 210 | 211 | ### Usage 212 | 213 | | Variable | Type | Default | 214 | | -------------- | :--------------------------------------------: | :------------ | 215 | | SUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | 216 | | SUI_DEBUG | `boolean` | `false` | 217 | | SUI_BIN_FOLDER | `string` | `"bin"` | 218 | | SUI_DB_FOLDER | `string` | `"db"` | 219 | | SINGBOX_API | `string` | - | 220 | 221 |
222 | 223 | ## SSL Certificate 224 | 225 |
226 | Click for details 227 | 228 | ### Certbot 229 | 230 | ```bash 231 | snap install core; snap refresh core 232 | snap install --classic certbot 233 | ln -s /snap/bin/certbot /usr/bin/certbot 234 | 235 | certbot certonly --standalone --register-unsafely-without-email --non-interactive --agree-tos -d 236 | ``` 237 | 238 |
239 | 240 | ## Stargazers over Time 241 | [![Stargazers over time](https://starchart.cc/alireza0/s-ui.svg)](https://starchart.cc/alireza0/s-ui) 242 | -------------------------------------------------------------------------------- /api/apiHandler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "s-ui/util/common" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type APIHandler struct { 11 | ApiService 12 | apiv2 *APIv2Handler 13 | } 14 | 15 | func NewAPIHandler(g *gin.RouterGroup, a2 *APIv2Handler) { 16 | a := &APIHandler{ 17 | apiv2: a2, 18 | } 19 | a.initRouter(g) 20 | } 21 | 22 | func (a *APIHandler) initRouter(g *gin.RouterGroup) { 23 | g.Use(func(c *gin.Context) { 24 | path := c.Request.URL.Path 25 | if !strings.HasSuffix(path, "login") && !strings.HasSuffix(path, "logout") { 26 | checkLogin(c) 27 | } 28 | }) 29 | g.POST("/:postAction", a.postHandler) 30 | g.GET("/:getAction", a.getHandler) 31 | } 32 | 33 | func (a *APIHandler) postHandler(c *gin.Context) { 34 | loginUser := GetLoginUser(c) 35 | action := c.Param("postAction") 36 | 37 | switch action { 38 | case "login": 39 | a.ApiService.Login(c) 40 | case "changePass": 41 | a.ApiService.ChangePass(c) 42 | case "save": 43 | a.ApiService.Save(c, loginUser) 44 | case "restartApp": 45 | a.ApiService.RestartApp(c) 46 | case "restartSb": 47 | a.ApiService.RestartSb(c) 48 | case "linkConvert": 49 | a.ApiService.LinkConvert(c) 50 | case "importdb": 51 | a.ApiService.ImportDb(c) 52 | case "addToken": 53 | a.ApiService.AddToken(c) 54 | a.apiv2.ReloadTokens() 55 | case "deleteToken": 56 | a.ApiService.DeleteToken(c) 57 | a.apiv2.ReloadTokens() 58 | default: 59 | jsonMsg(c, "failed", common.NewError("unknown action: ", action)) 60 | } 61 | } 62 | 63 | func (a *APIHandler) getHandler(c *gin.Context) { 64 | action := c.Param("getAction") 65 | 66 | switch action { 67 | case "logout": 68 | a.ApiService.Logout(c) 69 | case "load": 70 | a.ApiService.LoadData(c) 71 | case "inbounds", "outbounds", "endpoints", "tls", "clients", "config": 72 | err := a.ApiService.LoadPartialData(c, []string{action}) 73 | if err != nil { 74 | jsonMsg(c, action, err) 75 | } 76 | return 77 | case "users": 78 | a.ApiService.GetUsers(c) 79 | case "settings": 80 | a.ApiService.GetSettings(c) 81 | case "stats": 82 | a.ApiService.GetStats(c) 83 | case "status": 84 | a.ApiService.GetStatus(c) 85 | case "onlines": 86 | a.ApiService.GetOnlines(c) 87 | case "logs": 88 | a.ApiService.GetLogs(c) 89 | case "changes": 90 | a.ApiService.CheckChanges(c) 91 | case "keypairs": 92 | a.ApiService.GetKeypairs(c) 93 | case "getdb": 94 | a.ApiService.GetDb(c) 95 | case "tokens": 96 | a.ApiService.GetTokens(c) 97 | default: 98 | jsonMsg(c, "failed", common.NewError("unknown action: ", action)) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /api/apiService.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "s-ui/database" 6 | "s-ui/logger" 7 | "s-ui/service" 8 | "s-ui/util" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | type ApiService struct { 17 | service.SettingService 18 | service.UserService 19 | service.ConfigService 20 | service.ClientService 21 | service.TlsService 22 | service.InboundService 23 | service.OutboundService 24 | service.EndpointService 25 | service.PanelService 26 | service.StatsService 27 | service.ServerService 28 | } 29 | 30 | func (a *ApiService) LoadData(c *gin.Context) { 31 | data, err := a.getData(c) 32 | if err != nil { 33 | jsonMsg(c, "", err) 34 | return 35 | } 36 | jsonObj(c, data, nil) 37 | } 38 | 39 | func (a *ApiService) getData(c *gin.Context) (interface{}, error) { 40 | data := make(map[string]interface{}, 0) 41 | lu := c.Query("lu") 42 | isUpdated, err := a.ConfigService.CheckChanges(lu) 43 | if err != nil { 44 | return "", err 45 | } 46 | onlines, err := a.StatsService.GetOnlines() 47 | 48 | sysInfo := a.ServerService.GetSingboxInfo() 49 | if sysInfo["running"] == false { 50 | logs := a.ServerService.GetLogs("1", "debug") 51 | if len(logs) > 0 { 52 | data["lastLog"] = logs[0] 53 | } 54 | } 55 | 56 | if err != nil { 57 | return "", err 58 | } 59 | if isUpdated { 60 | config, err := a.SettingService.GetConfig() 61 | if err != nil { 62 | return "", err 63 | } 64 | clients, err := a.ClientService.GetAll() 65 | if err != nil { 66 | return "", err 67 | } 68 | tlsConfigs, err := a.TlsService.GetAll() 69 | if err != nil { 70 | return "", err 71 | } 72 | inbounds, err := a.InboundService.GetAll() 73 | if err != nil { 74 | return "", err 75 | } 76 | outbounds, err := a.OutboundService.GetAll() 77 | if err != nil { 78 | return "", err 79 | } 80 | endpoints, err := a.EndpointService.GetAll() 81 | if err != nil { 82 | return "", err 83 | } 84 | subURI, err := a.SettingService.GetFinalSubURI(strings.Split(c.Request.Host, ":")[0]) 85 | if err != nil { 86 | return "", err 87 | } 88 | data["config"] = json.RawMessage(config) 89 | data["clients"] = clients 90 | data["tls"] = tlsConfigs 91 | data["inbounds"] = inbounds 92 | data["outbounds"] = outbounds 93 | data["endpoints"] = endpoints 94 | data["subURI"] = subURI 95 | data["onlines"] = onlines 96 | } else { 97 | data["onlines"] = onlines 98 | } 99 | 100 | return data, nil 101 | } 102 | 103 | func (a *ApiService) LoadPartialData(c *gin.Context, objs []string) error { 104 | data := make(map[string]interface{}, 0) 105 | id := c.Query("id") 106 | 107 | for _, obj := range objs { 108 | switch obj { 109 | case "inbounds": 110 | inbounds, err := a.InboundService.Get(id) 111 | if err != nil { 112 | return err 113 | } 114 | data[obj] = inbounds 115 | case "outbounds": 116 | outbounds, err := a.OutboundService.GetAll() 117 | if err != nil { 118 | return err 119 | } 120 | data[obj] = outbounds 121 | case "endpoints": 122 | endpoints, err := a.EndpointService.GetAll() 123 | if err != nil { 124 | return err 125 | } 126 | data[obj] = endpoints 127 | case "tls": 128 | tlsConfigs, err := a.TlsService.GetAll() 129 | if err != nil { 130 | return err 131 | } 132 | data[obj] = tlsConfigs 133 | case "clients": 134 | clients, err := a.ClientService.Get(id) 135 | if err != nil { 136 | return err 137 | } 138 | data[obj] = clients 139 | case "config": 140 | config, err := a.SettingService.GetConfig() 141 | if err != nil { 142 | return err 143 | } 144 | data[obj] = json.RawMessage(config) 145 | case "settings": 146 | settings, err := a.SettingService.GetAllSetting() 147 | if err != nil { 148 | return err 149 | } 150 | data[obj] = settings 151 | } 152 | } 153 | 154 | jsonObj(c, data, nil) 155 | return nil 156 | } 157 | 158 | func (a *ApiService) GetUsers(c *gin.Context) { 159 | users, err := a.UserService.GetUsers() 160 | if err != nil { 161 | jsonMsg(c, "", err) 162 | return 163 | } 164 | jsonObj(c, *users, nil) 165 | } 166 | 167 | func (a *ApiService) GetSettings(c *gin.Context) { 168 | data, err := a.SettingService.GetAllSetting() 169 | if err != nil { 170 | jsonMsg(c, "", err) 171 | return 172 | } 173 | jsonObj(c, data, err) 174 | } 175 | 176 | func (a *ApiService) GetStats(c *gin.Context) { 177 | resource := c.Query("resource") 178 | tag := c.Query("tag") 179 | limit, err := strconv.Atoi(c.Query("limit")) 180 | if err != nil { 181 | limit = 100 182 | } 183 | data, err := a.StatsService.GetStats(resource, tag, limit) 184 | if err != nil { 185 | jsonMsg(c, "", err) 186 | return 187 | } 188 | jsonObj(c, data, err) 189 | } 190 | 191 | func (a *ApiService) GetStatus(c *gin.Context) { 192 | request := c.Query("r") 193 | result := a.ServerService.GetStatus(request) 194 | jsonObj(c, result, nil) 195 | } 196 | 197 | func (a *ApiService) GetOnlines(c *gin.Context) { 198 | onlines, err := a.StatsService.GetOnlines() 199 | jsonObj(c, onlines, err) 200 | } 201 | 202 | func (a *ApiService) GetLogs(c *gin.Context) { 203 | count := c.Query("c") 204 | level := c.Query("l") 205 | logs := a.ServerService.GetLogs(count, level) 206 | jsonObj(c, logs, nil) 207 | } 208 | 209 | func (a *ApiService) CheckChanges(c *gin.Context) { 210 | actor := c.Query("a") 211 | chngKey := c.Query("k") 212 | count := c.Query("c") 213 | changes := a.ConfigService.GetChanges(actor, chngKey, count) 214 | jsonObj(c, changes, nil) 215 | } 216 | 217 | func (a *ApiService) GetKeypairs(c *gin.Context) { 218 | kType := c.Query("k") 219 | options := c.Query("o") 220 | keypair := a.ServerService.GenKeypair(kType, options) 221 | jsonObj(c, keypair, nil) 222 | } 223 | 224 | func (a *ApiService) GetDb(c *gin.Context) { 225 | exclude := c.Query("exclude") 226 | db, err := database.GetDb(exclude) 227 | if err != nil { 228 | jsonMsg(c, "", err) 229 | return 230 | } 231 | c.Header("Content-Type", "application/octet-stream") 232 | c.Header("Content-Disposition", "attachment; filename=s-ui_"+time.Now().Format("20060102-150405")+".db") 233 | c.Writer.Write(db) 234 | } 235 | 236 | func (a *ApiService) postActions(c *gin.Context) (string, json.RawMessage, error) { 237 | var data map[string]json.RawMessage 238 | err := c.ShouldBind(&data) 239 | if err != nil { 240 | return "", nil, err 241 | } 242 | return string(data["action"]), data["data"], nil 243 | } 244 | 245 | func (a *ApiService) Login(c *gin.Context) { 246 | remoteIP := getRemoteIp(c) 247 | loginUser, err := a.UserService.Login(c.Request.FormValue("user"), c.Request.FormValue("pass"), remoteIP) 248 | if err != nil { 249 | jsonMsg(c, "", err) 250 | return 251 | } 252 | 253 | sessionMaxAge, err := a.SettingService.GetSessionMaxAge() 254 | if err != nil { 255 | logger.Infof("Unable to get session's max age from DB") 256 | } 257 | 258 | err = SetLoginUser(c, loginUser, sessionMaxAge) 259 | if err == nil { 260 | logger.Info("user ", loginUser, " login success") 261 | } else { 262 | logger.Warning("login failed: ", err) 263 | } 264 | 265 | jsonMsg(c, "", nil) 266 | } 267 | 268 | func (a *ApiService) ChangePass(c *gin.Context) { 269 | id := c.Request.FormValue("id") 270 | oldPass := c.Request.FormValue("oldPass") 271 | newUsername := c.Request.FormValue("newUsername") 272 | newPass := c.Request.FormValue("newPass") 273 | err := a.UserService.ChangePass(id, oldPass, newUsername, newPass) 274 | if err == nil { 275 | logger.Info("change user credentials success") 276 | jsonMsg(c, "save", nil) 277 | } else { 278 | logger.Warning("change user credentials failed:", err) 279 | jsonMsg(c, "", err) 280 | } 281 | } 282 | 283 | func (a *ApiService) Save(c *gin.Context, loginUser string) { 284 | hostname := getHostname(c) 285 | obj := c.Request.FormValue("object") 286 | act := c.Request.FormValue("action") 287 | data := c.Request.FormValue("data") 288 | initUsers := c.Request.FormValue("initUsers") 289 | objs, err := a.ConfigService.Save(obj, act, json.RawMessage(data), initUsers, loginUser, hostname) 290 | if err != nil { 291 | jsonMsg(c, "save", err) 292 | return 293 | } 294 | err = a.LoadPartialData(c, objs) 295 | if err != nil { 296 | jsonMsg(c, obj, err) 297 | } 298 | } 299 | 300 | func (a *ApiService) RestartApp(c *gin.Context) { 301 | err := a.PanelService.RestartPanel(3) 302 | jsonMsg(c, "restartApp", err) 303 | } 304 | 305 | func (a *ApiService) RestartSb(c *gin.Context) { 306 | err := a.ConfigService.RestartCore() 307 | jsonMsg(c, "restartSb", err) 308 | } 309 | 310 | func (a *ApiService) LinkConvert(c *gin.Context) { 311 | link := c.Request.FormValue("link") 312 | result, _, err := util.GetOutbound(link, 0) 313 | jsonObj(c, result, err) 314 | } 315 | 316 | func (a *ApiService) ImportDb(c *gin.Context) { 317 | file, _, err := c.Request.FormFile("db") 318 | if err != nil { 319 | jsonMsg(c, "", err) 320 | return 321 | } 322 | defer file.Close() 323 | err = database.ImportDB(file) 324 | jsonMsg(c, "", err) 325 | } 326 | 327 | func (a *ApiService) Logout(c *gin.Context) { 328 | loginUser := GetLoginUser(c) 329 | if loginUser != "" { 330 | logger.Infof("user %s logout", loginUser) 331 | } 332 | ClearSession(c) 333 | jsonMsg(c, "", nil) 334 | } 335 | 336 | func (a *ApiService) LoadTokens() ([]byte, error) { 337 | return a.UserService.LoadTokens() 338 | } 339 | 340 | func (a *ApiService) GetTokens(c *gin.Context) { 341 | loginUser := GetLoginUser(c) 342 | tokens, err := a.UserService.GetUserTokens(loginUser) 343 | jsonObj(c, tokens, err) 344 | } 345 | 346 | func (a *ApiService) AddToken(c *gin.Context) { 347 | loginUser := GetLoginUser(c) 348 | expiry := c.Request.FormValue("expiry") 349 | expiryInt, err := strconv.ParseInt(expiry, 10, 64) 350 | if err != nil { 351 | jsonMsg(c, "", err) 352 | return 353 | } 354 | desc := c.Request.FormValue("desc") 355 | token, err := a.UserService.AddToken(loginUser, expiryInt, desc) 356 | jsonObj(c, token, err) 357 | } 358 | 359 | func (a *ApiService) DeleteToken(c *gin.Context) { 360 | tokenId := c.Request.FormValue("id") 361 | err := a.UserService.DeleteToken(tokenId) 362 | jsonMsg(c, "", err) 363 | } 364 | -------------------------------------------------------------------------------- /api/apiV2Handler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "s-ui/logger" 6 | "s-ui/util/common" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type TokenInMemory struct { 13 | Token string 14 | Expiry int64 15 | Username string 16 | } 17 | 18 | type APIv2Handler struct { 19 | ApiService 20 | tokens *[]TokenInMemory 21 | } 22 | 23 | func NewAPIv2Handler(g *gin.RouterGroup) *APIv2Handler { 24 | a := &APIv2Handler{} 25 | a.ReloadTokens() 26 | a.initRouter(g) 27 | return a 28 | } 29 | 30 | func (a *APIv2Handler) initRouter(g *gin.RouterGroup) { 31 | g.Use(func(c *gin.Context) { 32 | a.checkToken(c) 33 | }) 34 | g.POST("/:postAction", a.postHandler) 35 | g.GET("/:getAction", a.getHandler) 36 | } 37 | 38 | func (a *APIv2Handler) postHandler(c *gin.Context) { 39 | username := a.findUsername(c) 40 | action := c.Param("postAction") 41 | 42 | switch action { 43 | case "save": 44 | a.ApiService.Save(c, username) 45 | case "restartApp": 46 | a.ApiService.RestartApp(c) 47 | case "restartSb": 48 | a.ApiService.RestartSb(c) 49 | case "linkConvert": 50 | a.ApiService.LinkConvert(c) 51 | case "importdb": 52 | a.ApiService.ImportDb(c) 53 | default: 54 | jsonMsg(c, "failed", common.NewError("unknown action: ", action)) 55 | } 56 | } 57 | 58 | func (a *APIv2Handler) getHandler(c *gin.Context) { 59 | action := c.Param("getAction") 60 | 61 | switch action { 62 | case "load": 63 | a.ApiService.LoadData(c) 64 | case "inbounds", "outbounds", "endpoints", "tls", "clients", "config": 65 | err := a.ApiService.LoadPartialData(c, []string{action}) 66 | if err != nil { 67 | jsonMsg(c, action, err) 68 | } 69 | return 70 | case "users": 71 | a.ApiService.GetUsers(c) 72 | case "settings": 73 | a.ApiService.GetSettings(c) 74 | case "stats": 75 | a.ApiService.GetStats(c) 76 | case "status": 77 | a.ApiService.GetStatus(c) 78 | case "onlines": 79 | a.ApiService.GetOnlines(c) 80 | case "logs": 81 | a.ApiService.GetLogs(c) 82 | case "changes": 83 | a.ApiService.CheckChanges(c) 84 | case "keypairs": 85 | a.ApiService.GetKeypairs(c) 86 | case "getdb": 87 | a.ApiService.GetDb(c) 88 | default: 89 | jsonMsg(c, "failed", common.NewError("unknown action: ", action)) 90 | } 91 | } 92 | 93 | func (a *APIv2Handler) findUsername(c *gin.Context) string { 94 | token := c.Request.Header.Get("Token") 95 | for index, t := range *a.tokens { 96 | if t.Expiry > 0 && t.Expiry < time.Now().Unix() { 97 | (*a.tokens) = append((*a.tokens)[:index], (*a.tokens)[index+1:]...) 98 | continue 99 | } 100 | if t.Token == token { 101 | return t.Username 102 | } 103 | } 104 | return "" 105 | } 106 | 107 | func (a *APIv2Handler) checkToken(c *gin.Context) { 108 | username := a.findUsername(c) 109 | if username != "" { 110 | c.Next() 111 | return 112 | } 113 | jsonMsg(c, "", common.NewError("invalid token")) 114 | c.Abort() 115 | } 116 | 117 | func (a *APIv2Handler) ReloadTokens() { 118 | tokens, err := a.ApiService.LoadTokens() 119 | if err == nil { 120 | var newTokens []TokenInMemory 121 | err = json.Unmarshal(tokens, &newTokens) 122 | if err != nil { 123 | logger.Error("unable to load tokens: ", err) 124 | } 125 | a.tokens = &newTokens 126 | } else { 127 | logger.Error("unable to load tokens: ", err) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /api/session.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/gob" 5 | "s-ui/database/model" 6 | 7 | "github.com/gin-contrib/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, maxAge int) error { 20 | options := sessions.Options{ 21 | Path: "/", 22 | Secure: false, 23 | } 24 | if maxAge > 0 { 25 | options.MaxAge = maxAge * 60 26 | } 27 | 28 | s := sessions.Default(c) 29 | s.Set(loginUser, userName) 30 | s.Options(options) 31 | 32 | return s.Save() 33 | } 34 | 35 | func SetMaxAge(c *gin.Context) error { 36 | s := sessions.Default(c) 37 | s.Options(sessions.Options{ 38 | Path: "/", 39 | }) 40 | return s.Save() 41 | } 42 | 43 | func GetLoginUser(c *gin.Context) string { 44 | s := sessions.Default(c) 45 | obj := s.Get(loginUser) 46 | if obj == nil { 47 | return "" 48 | } 49 | objStr, ok := obj.(string) 50 | if !ok { 51 | return "" 52 | } 53 | return objStr 54 | } 55 | 56 | func IsLogin(c *gin.Context) bool { 57 | return GetLoginUser(c) != "" 58 | } 59 | 60 | func ClearSession(c *gin.Context) { 61 | s := sessions.Default(c) 62 | s.Clear() 63 | s.Options(sessions.Options{ 64 | Path: "/", 65 | MaxAge: -1, 66 | }) 67 | s.Save() 68 | } 69 | -------------------------------------------------------------------------------- /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 getHostname(c *gin.Context) string { 31 | host := c.Request.Host 32 | if colonIndex := strings.LastIndex(host, ":"); colonIndex != -1 { 33 | host, _, _ = net.SplitHostPort(c.Request.Host) 34 | } 35 | return host 36 | } 37 | 38 | func jsonMsg(c *gin.Context, msg string, err error) { 39 | jsonMsgObj(c, msg, nil, err) 40 | } 41 | 42 | func jsonObj(c *gin.Context, obj interface{}, err error) { 43 | jsonMsgObj(c, "", obj, err) 44 | } 45 | 46 | func jsonMsgObj(c *gin.Context, msg string, obj interface{}, err error) { 47 | m := Msg{ 48 | Obj: obj, 49 | } 50 | if err == nil { 51 | m.Success = true 52 | if msg != "" { 53 | m.Msg = msg 54 | } 55 | } else { 56 | m.Success = false 57 | m.Msg = msg + ": " + err.Error() 58 | logger.Warning("failed :", err) 59 | } 60 | c.JSON(http.StatusOK, m) 61 | } 62 | 63 | func pureJsonMsg(c *gin.Context, success bool, msg string) { 64 | if success { 65 | c.JSON(http.StatusOK, Msg{ 66 | Success: true, 67 | Msg: msg, 68 | }) 69 | } else { 70 | c.JSON(http.StatusOK, Msg{ 71 | Success: false, 72 | Msg: msg, 73 | }) 74 | } 75 | } 76 | 77 | func checkLogin(c *gin.Context) { 78 | if !IsLogin(c) { 79 | if c.GetHeader("X-Requested-With") == "XMLHttpRequest" { 80 | pureJsonMsg(c, false, "Invalid login") 81 | } else { 82 | c.Redirect(http.StatusTemporaryRedirect, "/login") 83 | } 84 | c.Abort() 85 | } else { 86 | c.Next() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "s-ui/config" 6 | "s-ui/core" 7 | "s-ui/cronjob" 8 | "s-ui/database" 9 | "s-ui/logger" 10 | "s-ui/service" 11 | "s-ui/sub" 12 | "s-ui/web" 13 | 14 | "github.com/op/go-logging" 15 | ) 16 | 17 | type APP struct { 18 | service.SettingService 19 | configService *service.ConfigService 20 | webServer *web.Server 21 | subServer *sub.Server 22 | cronJob *cronjob.CronJob 23 | logger *logging.Logger 24 | core *core.Core 25 | } 26 | 27 | func NewApp() *APP { 28 | return &APP{} 29 | } 30 | 31 | func (a *APP) Init() error { 32 | log.Printf("%v %v", config.GetName(), config.GetVersion()) 33 | 34 | a.initLog() 35 | 36 | err := database.InitDB(config.GetDBPath()) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | // Init Setting 42 | a.SettingService.GetAllSetting() 43 | 44 | a.core = core.NewCore() 45 | 46 | a.cronJob = cronjob.NewCronJob() 47 | a.webServer = web.NewServer() 48 | a.subServer = sub.NewServer() 49 | 50 | a.configService = service.NewConfigService(a.core) 51 | 52 | return nil 53 | } 54 | 55 | func (a *APP) Start() error { 56 | loc, err := a.SettingService.GetTimeLocation() 57 | if err != nil { 58 | return err 59 | } 60 | 61 | trafficAge, err := a.SettingService.GetTrafficAge() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | err = a.cronJob.Start(loc, trafficAge) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | err = a.webServer.Start() 72 | if err != nil { 73 | return err 74 | } 75 | 76 | err = a.subServer.Start() 77 | if err != nil { 78 | return err 79 | } 80 | 81 | err = a.configService.StartCore("") 82 | if err != nil { 83 | logger.Error(err) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func (a *APP) Stop() { 90 | a.cronJob.Stop() 91 | err := a.subServer.Stop() 92 | if err != nil { 93 | logger.Warning("stop Sub Server err:", err) 94 | } 95 | err = a.webServer.Stop() 96 | if err != nil { 97 | logger.Warning("stop Web Server err:", err) 98 | } 99 | err = a.configService.StopCore() 100 | if err != nil { 101 | logger.Warning("stop Core err:", err) 102 | } 103 | } 104 | 105 | func (a *APP) initLog() { 106 | switch config.GetLogLevel() { 107 | case config.Debug: 108 | logger.InitLogger(logging.DEBUG) 109 | case config.Info: 110 | logger.InitLogger(logging.INFO) 111 | case config.Warn: 112 | logger.InitLogger(logging.WARNING) 113 | case config.Error: 114 | logger.InitLogger(logging.ERROR) 115 | default: 116 | log.Fatal("unknown log level:", config.GetLogLevel()) 117 | } 118 | } 119 | 120 | func (a *APP) RestartApp() { 121 | a.Stop() 122 | a.Start() 123 | } 124 | 125 | func (a *APP) GetCore() *core.Core { 126 | return a.core 127 | } 128 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd frontend 4 | npm i 5 | npm run build 6 | 7 | cd .. 8 | echo "Backend" 9 | 10 | mkdir -p web/html 11 | rm -fr web/html/* 12 | cp -R frontend/dist/* web/html/ 13 | 14 | go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_ech,with_utls,with_reality_server,with_acme,with_gvisor" -o sui main.go 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "runtime/debug" 8 | "s-ui/cmd/migration" 9 | "s-ui/config" 10 | ) 11 | 12 | func ParseCmd() { 13 | var showVersion bool 14 | flag.BoolVar(&showVersion, "v", false, "show version") 15 | 16 | adminCmd := flag.NewFlagSet("admin", flag.ExitOnError) 17 | settingCmd := flag.NewFlagSet("setting", flag.ExitOnError) 18 | 19 | var username string 20 | var password string 21 | var port int 22 | var path string 23 | var subPort int 24 | var subPath string 25 | var reset bool 26 | var show bool 27 | settingCmd.BoolVar(&reset, "reset", false, "reset all settings") 28 | settingCmd.BoolVar(&show, "show", false, "show current settings") 29 | settingCmd.IntVar(&port, "port", 0, "set panel port") 30 | settingCmd.StringVar(&path, "path", "", "set panel path") 31 | settingCmd.IntVar(&subPort, "subPort", 0, "set sub port") 32 | settingCmd.StringVar(&subPath, "subPath", "", "set sub path") 33 | 34 | adminCmd.BoolVar(&show, "show", false, "show first admin credentials") 35 | adminCmd.BoolVar(&reset, "reset", false, "reset first admin credentials") 36 | adminCmd.StringVar(&username, "username", "", "set login username") 37 | adminCmd.StringVar(&password, "password", "", "set login password") 38 | 39 | oldUsage := flag.Usage 40 | flag.Usage = func() { 41 | oldUsage() 42 | fmt.Println() 43 | fmt.Println("Commands:") 44 | fmt.Println(" admin set/reset/show first admin credentials") 45 | fmt.Println(" uri Show panel URI") 46 | fmt.Println(" migrate migrate form older version") 47 | fmt.Println(" setting set/reset/show settings") 48 | fmt.Println() 49 | adminCmd.Usage() 50 | fmt.Println() 51 | settingCmd.Usage() 52 | } 53 | 54 | flag.Parse() 55 | if showVersion { 56 | fmt.Println("S-UI Panel\t", config.GetVersion()) 57 | info, ok := debug.ReadBuildInfo() 58 | if ok { 59 | for _, dep := range info.Deps { 60 | if dep.Path == "github.com/sagernet/sing-box" { 61 | fmt.Println("Sing-Box\t", dep.Version) 62 | break 63 | } 64 | } 65 | } 66 | return 67 | } 68 | 69 | switch os.Args[1] { 70 | case "admin": 71 | err := adminCmd.Parse(os.Args[2:]) 72 | if err != nil { 73 | fmt.Println(err) 74 | return 75 | } 76 | switch { 77 | case show: 78 | showAdmin() 79 | case reset: 80 | resetAdmin() 81 | default: 82 | updateAdmin(username, password) 83 | showAdmin() 84 | } 85 | 86 | case "uri": 87 | getPanelURI() 88 | 89 | case "migrate": 90 | migration.MigrateDb() 91 | 92 | case "setting": 93 | err := settingCmd.Parse(os.Args[2:]) 94 | if err != nil { 95 | fmt.Println(err) 96 | return 97 | } 98 | switch { 99 | case show: 100 | showSetting() 101 | case reset: 102 | resetSetting() 103 | default: 104 | updateSetting(port, path, subPort, subPath) 105 | showSetting() 106 | } 107 | default: 108 | fmt.Println("Invalid subcommands") 109 | flag.Usage() 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /cmd/migration/1_1.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "s-ui/database/model" 7 | "strings" 8 | 9 | "gorm.io/gorm" 10 | ) 11 | 12 | func migrateClientSchema(db *gorm.DB) error { 13 | rows, err := db.Raw("PRAGMA table_info(clients)").Rows() 14 | if err != nil { 15 | fmt.Println(err) 16 | return err 17 | } 18 | defer rows.Close() 19 | 20 | for rows.Next() { 21 | var ( 22 | cid int 23 | cname string 24 | ctype string 25 | notnull int 26 | dfltValue interface{} 27 | pk int 28 | ) 29 | 30 | rows.Scan(&cid, &cname, &ctype, ¬null, &dfltValue, &pk) 31 | if cname == "config" || cname == "inbounds" || cname == "links" { 32 | if ctype == "text" { 33 | fmt.Printf("Column %s has type TEXT\n", cname) 34 | oldData := make([]struct { 35 | Id uint 36 | Data string 37 | }, 0) 38 | db.Model(model.Client{}).Select("id", cname+" as data").Scan(&oldData) 39 | for _, data := range oldData { 40 | var newData []byte 41 | switch cname { 42 | case "inbounds": 43 | inbounds := strings.Split(data.Data, ",") 44 | newData, _ = json.MarshalIndent(inbounds, "", " ") 45 | case "config": 46 | jsonData := map[string]interface{}{} 47 | json.Unmarshal([]byte(data.Data), &jsonData) 48 | newData, _ = json.MarshalIndent(jsonData, "", " ") 49 | case "links": 50 | jsonData := make([]interface{}, 0) 51 | json.Unmarshal([]byte(data.Data), &jsonData) 52 | newData, _ = json.MarshalIndent(jsonData, "", " ") 53 | } 54 | err = db.Model(model.Client{}).Where("id = ?", data.Id).UpdateColumn(cname, newData).Error 55 | if err != nil { 56 | return err 57 | } 58 | } 59 | } 60 | } 61 | } 62 | return nil 63 | } 64 | 65 | func deleteOldWebSecret(db *gorm.DB) error { 66 | return db.Exec("DELETE FROM settings WHERE key = ?", "webSecret").Error 67 | } 68 | 69 | func changesObj(db *gorm.DB) error { 70 | return db.Exec("UPDATE changes SET obj = CAST('\"' || CAST(obj AS TEXT) || '\"' AS BLOB) WHERE actor = ? and obj not like ?", "DepleteJob", "\"%\"").Error 71 | } 72 | 73 | func to1_1(db *gorm.DB) error { 74 | err := migrateClientSchema(db) 75 | if err != nil { 76 | return err 77 | } 78 | err = deleteOldWebSecret(db) 79 | if err != nil { 80 | return err 81 | } 82 | err = changesObj(db) 83 | if err != nil { 84 | return err 85 | } 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /cmd/migration/1_2.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "s-ui/database/model" 9 | 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type InboundData struct { 14 | Id uint 15 | Tag string 16 | Addrs json.RawMessage 17 | OutJson json.RawMessage 18 | } 19 | 20 | func moveJsonToDb(db *gorm.DB) error { 21 | binFolderPath := os.Getenv("SUI_BIN_FOLDER") 22 | if binFolderPath == "" { 23 | binFolderPath = "bin" 24 | } 25 | dir, err := filepath.Abs(filepath.Dir(os.Args[0])) 26 | if err != nil { 27 | return err 28 | } 29 | configPath := dir + "/" + binFolderPath + "/config.json" 30 | if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { 31 | return nil 32 | } 33 | 34 | data, err := os.ReadFile(configPath) 35 | if err != nil { 36 | return err 37 | } 38 | var oldConfig map[string]interface{} 39 | err = json.Unmarshal(data, &oldConfig) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | oldInbounds := oldConfig["inbounds"].([]interface{}) 45 | db.Migrator().DropTable(&model.Inbound{}) 46 | db.AutoMigrate(&model.Inbound{}) 47 | for _, inbound := range oldInbounds { 48 | inbObj, _ := inbound.(map[string]interface{}) 49 | tag, _ := inbObj["tag"].(string) 50 | if tlsObj, ok := inbObj["tls"]; ok { 51 | var tls_id uint 52 | err = db.Raw("SELECT id FROM tls WHERE inbounds like ?", `%"`+tag+`"%`).Find(&tls_id).Error 53 | if err != nil { 54 | return err 55 | } 56 | 57 | // Bind or Create tls_id 58 | if tls_id > 0 { 59 | inbObj["tls_id"] = tls_id 60 | } else { 61 | tls_server, _ := json.MarshalIndent(tlsObj, "", " ") 62 | if len(tls_server) > 5 { 63 | newTls := &model.Tls{ 64 | Name: tag, 65 | Server: tls_server, 66 | Client: json.RawMessage("{}"), 67 | } 68 | err = db.Create(newTls).Error 69 | if err != nil { 70 | return err 71 | } 72 | inbObj["tls_id"] = newTls.Id 73 | } 74 | } 75 | } 76 | 77 | var inbData InboundData 78 | db.Raw("select id,addrs,out_json from inbound_data where tag = ?", tag).Find(&inbData) 79 | if inbData.Id > 0 { 80 | inbObj["out_json"] = inbData.OutJson 81 | var addrs []map[string]interface{} 82 | json.Unmarshal(inbData.Addrs, &addrs) 83 | for index, addr := range addrs { 84 | if tlsEnable, ok := addr["tls"].(bool); ok { 85 | newTls := map[string]interface{}{ 86 | "enabled": tlsEnable, 87 | } 88 | if insecure, ok := addr["insecure"].(bool); ok { 89 | newTls["insecure"] = insecure 90 | delete(addrs[index], "insecure") 91 | } 92 | if sni, ok := addr["server_name"].(string); ok { 93 | newTls["server_name"] = sni 94 | delete(addrs[index], "server_name") 95 | } 96 | addrs[index]["tls"] = newTls 97 | } 98 | } 99 | inbObj["addrs"] = addrs 100 | } else { 101 | inbObj["out_json"] = json.RawMessage("{}") 102 | inbObj["addrs"] = json.RawMessage("[]") 103 | } 104 | // Delete deprecated fields 105 | delete(inbObj, "sniff") 106 | delete(inbObj, "sniff_override_destination") 107 | delete(inbObj, "sniff_timeout") 108 | delete(inbObj, "domain_strategy") 109 | inbJson, _ := json.Marshal(inbObj) 110 | 111 | var newInbound model.Inbound 112 | err = newInbound.UnmarshalJSON(inbJson) 113 | if err != nil { 114 | return err 115 | } 116 | err = db.Create(&newInbound).Error 117 | if err != nil { 118 | return err 119 | } 120 | } 121 | delete(oldConfig, "inbounds") 122 | 123 | blockOutboundTags := []string{} 124 | dnsOutboundTags := []string{} 125 | 126 | oldOutbounds := oldConfig["outbounds"].([]interface{}) 127 | db.Migrator().DropTable(&model.Outbound{}, &model.Endpoint{}) 128 | db.AutoMigrate(&model.Outbound{}, &model.Endpoint{}) 129 | for _, outbound := range oldOutbounds { 130 | outType, _ := outbound.(map[string]interface{})["type"].(string) 131 | outboundRaw, _ := json.MarshalIndent(outbound, "", " ") 132 | if outType == "wireguard" { // Check if it is Entrypoint 133 | var newEntrypoint model.Endpoint 134 | err = newEntrypoint.UnmarshalJSON(outboundRaw) 135 | if err != nil { 136 | return err 137 | } 138 | err = db.Create(&newEntrypoint).Error 139 | if err != nil { 140 | return err 141 | } 142 | } else { // It is Outbound 143 | var newOutbound model.Outbound 144 | err = newOutbound.UnmarshalJSON(outboundRaw) 145 | if err != nil { 146 | return err 147 | } 148 | // Delete deprecated fields 149 | if newOutbound.Type == "direct" { 150 | var options map[string]interface{} 151 | json.Unmarshal(newOutbound.Options, &options) 152 | delete(options, "override_address") 153 | delete(options, "override_port") 154 | newOutbound.Options, _ = json.Marshal(options) 155 | } 156 | 157 | switch newOutbound.Type { 158 | case "dns": 159 | dnsOutboundTags = append(dnsOutboundTags, newOutbound.Tag) 160 | case "block": 161 | blockOutboundTags = append(blockOutboundTags, newOutbound.Tag) 162 | default: 163 | err = db.Create(&newOutbound).Error 164 | if err != nil { 165 | return err 166 | } 167 | } 168 | } 169 | } 170 | delete(oldConfig, "outbounds") 171 | 172 | // Check routing rules 173 | if routingRules, ok := oldConfig["route"].(map[string]interface{}); ok { 174 | if rules, hasRules := routingRules["rules"].([]interface{}); hasRules { 175 | hasDns := false 176 | for index, rule := range rules { 177 | ruleObj, _ := rule.(map[string]interface{}) 178 | isBlock := false 179 | isDns := false 180 | outboundTag, _ := ruleObj["outbound"].(string) 181 | for _, tag := range blockOutboundTags { 182 | if tag == outboundTag { 183 | isBlock = true 184 | delete(ruleObj, "outbound") 185 | ruleObj["action"] = "reject" 186 | break 187 | } 188 | } 189 | for _, tag := range dnsOutboundTags { 190 | if tag == outboundTag { 191 | isDns = true 192 | hasDns = true 193 | delete(ruleObj, "outbound") 194 | ruleObj["action"] = "hijack-dns" 195 | break 196 | } 197 | } 198 | if !isBlock && !isDns { 199 | ruleObj["action"] = "route" 200 | } 201 | rules[index] = ruleObj 202 | } 203 | if hasDns { 204 | rules = append(rules, map[string]interface{}{"action": "sniff"}) 205 | } 206 | routingRules["rules"] = rules 207 | } 208 | oldConfig["route"] = routingRules 209 | } 210 | 211 | // Remove v2rayapi and clashapi from experimental config 212 | experimental := oldConfig["experimental"].(map[string]interface{}) 213 | delete(experimental, "v2ray_api") 214 | delete(experimental, "clash_api") 215 | oldConfig["experimental"] = experimental 216 | 217 | // Save the other configs 218 | var otherConfigs json.RawMessage 219 | otherConfigs, err = json.MarshalIndent(oldConfig, "", " ") 220 | if err != nil { 221 | return err 222 | } 223 | 224 | return db.Save(&model.Setting{ 225 | Key: "config", 226 | Value: string(otherConfigs), 227 | }).Error 228 | } 229 | 230 | func migrateTls(db *gorm.DB) error { 231 | if !db.Migrator().HasColumn(&model.Tls{}, "inbounds") { 232 | return nil 233 | } 234 | err := db.Migrator().DropColumn(&model.Tls{}, "inbounds") 235 | if err != nil { 236 | return err 237 | } 238 | var tlsConfig []model.Tls 239 | err = db.Model(model.Tls{}).Scan(&tlsConfig).Error 240 | if err != nil { 241 | return err 242 | } 243 | 244 | for index, tls := range tlsConfig { 245 | var tlsClient map[string]interface{} 246 | err = json.Unmarshal(tls.Client, &tlsClient) 247 | if err != nil { 248 | continue 249 | } 250 | for key := range tlsClient { 251 | switch key { 252 | case "insecure", "disable_sni", "utls", "ech", "reality": 253 | continue 254 | default: 255 | delete(tlsClient, key) 256 | } 257 | } 258 | tlsConfig[index].Client, _ = json.MarshalIndent(tlsClient, "", " ") 259 | } 260 | 261 | return db.Save(&tlsConfig).Error 262 | } 263 | 264 | func dropInboundData(db *gorm.DB) error { 265 | if !db.Migrator().HasTable(&InboundData{}) { 266 | return nil 267 | } 268 | return db.Migrator().DropTable(&InboundData{}) 269 | } 270 | 271 | func migrateClients(db *gorm.DB) error { 272 | var oldClients []model.Client 273 | err := db.Model(model.Client{}).Scan(&oldClients).Error 274 | if err != nil { 275 | return err 276 | } 277 | 278 | for index, oldClient := range oldClients { 279 | var old_inbounds []string 280 | err = json.Unmarshal(oldClient.Inbounds, &old_inbounds) 281 | if err != nil { 282 | return err 283 | } 284 | var inbound_ids []uint 285 | err = db.Raw("SELECT id FROM inbounds WHERE tag in ?", old_inbounds).Find(&inbound_ids).Error 286 | if err != nil { 287 | return err 288 | } 289 | oldClients[index].Inbounds, _ = json.Marshal(inbound_ids) 290 | } 291 | return db.Save(oldClients).Error 292 | } 293 | 294 | func migrateChanges(db *gorm.DB) error { 295 | return db.Migrator().DropColumn(&model.Changes{}, "index") 296 | } 297 | 298 | func to1_2(db *gorm.DB) error { 299 | err := moveJsonToDb(db) 300 | if err != nil { 301 | return err 302 | } 303 | err = migrateTls(db) 304 | if err != nil { 305 | return err 306 | } 307 | err = dropInboundData(db) 308 | if err != nil { 309 | return err 310 | } 311 | err = migrateClients(db) 312 | if err != nil { 313 | return err 314 | } 315 | return migrateChanges(db) 316 | } 317 | -------------------------------------------------------------------------------- /cmd/migration/main.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "s-ui/config" 8 | 9 | "gorm.io/driver/sqlite" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | func MigrateDb() { 14 | // void running on first install 15 | path := config.GetDBPath() 16 | _, err := os.Stat(path) 17 | if err != nil { 18 | println("Database not found") 19 | return 20 | } 21 | 22 | db, err := gorm.Open(sqlite.Open(path)) 23 | if err != nil { 24 | log.Fatal(err) 25 | return 26 | } 27 | tx := db.Begin() 28 | defer func() { 29 | if err == nil { 30 | tx.Commit() 31 | } else { 32 | tx.Rollback() 33 | } 34 | }() 35 | currentVersion := config.GetVersion() 36 | dbVersion := "" 37 | tx.Raw("SELECT value FROM settings WHERE key = ?", "version").Find(&dbVersion) 38 | fmt.Println("Current version:", currentVersion, "\nDatabase version:", dbVersion) 39 | 40 | if currentVersion == dbVersion { 41 | fmt.Println("Database is up to date, no need to migrate") 42 | return 43 | } 44 | 45 | fmt.Println("Start migrating database...") 46 | 47 | // Before 1.2 48 | if dbVersion == "" { 49 | err = to1_1(tx) 50 | if err != nil { 51 | log.Fatal("Migration to 1.1 failed: ", err) 52 | return 53 | } 54 | err = to1_2(tx) 55 | if err != nil { 56 | log.Fatal("Migration to 1.2 failed: ", err) 57 | return 58 | } 59 | } 60 | 61 | // Set version 62 | err = tx.Raw("UPDATE settings SET value = ? WHERE key = ?", currentVersion, "version").Error 63 | if err != nil { 64 | log.Fatal("Update version failed: ", err) 65 | return 66 | } 67 | fmt.Println("Migration done!") 68 | } 69 | -------------------------------------------------------------------------------- /cmd/setting.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "s-ui/config" 8 | "s-ui/database" 9 | "s-ui/service" 10 | "strings" 11 | 12 | "github.com/shirou/gopsutil/v4/net" 13 | ) 14 | 15 | func resetSetting() { 16 | err := database.InitDB(config.GetDBPath()) 17 | if err != nil { 18 | fmt.Println(err) 19 | return 20 | } 21 | 22 | settingService := service.SettingService{} 23 | err = settingService.ResetSettings() 24 | if err != nil { 25 | fmt.Println("reset setting failed:", err) 26 | } else { 27 | fmt.Println("reset setting success") 28 | } 29 | } 30 | 31 | func updateSetting(port int, path string, subPort int, subPath string) { 32 | err := database.InitDB(config.GetDBPath()) 33 | if err != nil { 34 | fmt.Println(err) 35 | return 36 | } 37 | 38 | settingService := service.SettingService{} 39 | 40 | if port > 0 { 41 | err := settingService.SetPort(port) 42 | if err != nil { 43 | fmt.Println("set port failed:", err) 44 | } else { 45 | fmt.Println("set port success") 46 | } 47 | } 48 | if path != "" { 49 | err := settingService.SetWebPath(path) 50 | if err != nil { 51 | fmt.Println("set path failed:", err) 52 | } else { 53 | fmt.Println("set path success") 54 | } 55 | } 56 | if subPort > 0 { 57 | err := settingService.SetSubPort(subPort) 58 | if err != nil { 59 | fmt.Println("set sub port failed:", err) 60 | } else { 61 | fmt.Println("set sub port success") 62 | } 63 | } 64 | if subPath != "" { 65 | err := settingService.SetSubPath(subPath) 66 | if err != nil { 67 | fmt.Println("set sub path failed:", err) 68 | } else { 69 | fmt.Println("set sub path success") 70 | } 71 | } 72 | } 73 | 74 | func showSetting() { 75 | err := database.InitDB(config.GetDBPath()) 76 | if err != nil { 77 | fmt.Println(err) 78 | return 79 | } 80 | settingService := service.SettingService{} 81 | allSetting, err := settingService.GetAllSetting() 82 | if err != nil { 83 | fmt.Println("get current port failed,error info:", err) 84 | } 85 | fmt.Println("Current panel settings:") 86 | fmt.Println("\tPanel port:\t", (*allSetting)["webPort"]) 87 | fmt.Println("\tPanel path:\t", (*allSetting)["webPath"]) 88 | if (*allSetting)["webListen"] != "" { 89 | fmt.Println("\tPanel IP:\t", (*allSetting)["webListen"]) 90 | } 91 | if (*allSetting)["webDomain"] != "" { 92 | fmt.Println("\tPanel Domain:\t", (*allSetting)["webDomain"]) 93 | } 94 | if (*allSetting)["webURI"] != "" { 95 | fmt.Println("\tPanel URI:\t", (*allSetting)["webURI"]) 96 | } 97 | fmt.Println() 98 | fmt.Println("Current subscription settings:") 99 | fmt.Println("\tSub port:\t", (*allSetting)["subPort"]) 100 | fmt.Println("\tSub path:\t", (*allSetting)["subPath"]) 101 | if (*allSetting)["subListen"] != "" { 102 | fmt.Println("\tSub IP:\t", (*allSetting)["subListen"]) 103 | } 104 | if (*allSetting)["subDomain"] != "" { 105 | fmt.Println("\tSub Domain:\t", (*allSetting)["subDomain"]) 106 | } 107 | if (*allSetting)["subURI"] != "" { 108 | fmt.Println("\tSub URI:\t", (*allSetting)["subURI"]) 109 | } 110 | } 111 | 112 | func getPanelURI() { 113 | err := database.InitDB(config.GetDBPath()) 114 | if err != nil { 115 | fmt.Println(err) 116 | return 117 | } 118 | settingService := service.SettingService{} 119 | Port, _ := settingService.GetPort() 120 | BasePath, _ := settingService.GetWebPath() 121 | Listen, _ := settingService.GetListen() 122 | Domain, _ := settingService.GetWebDomain() 123 | KeyFile, _ := settingService.GetKeyFile() 124 | CertFile, _ := settingService.GetCertFile() 125 | TLS := false 126 | if KeyFile != "" && CertFile != "" { 127 | TLS = true 128 | } 129 | Proto := "" 130 | if TLS { 131 | Proto = "https://" 132 | } else { 133 | Proto = "http://" 134 | } 135 | PortText := fmt.Sprintf(":%d", Port) 136 | if (Port == 443 && TLS) || (Port == 80 && !TLS) { 137 | PortText = "" 138 | } 139 | if len(Domain) > 0 { 140 | fmt.Println(Proto + Domain + PortText + BasePath) 141 | return 142 | } 143 | if len(Listen) > 0 { 144 | fmt.Println(Proto + Listen + PortText + BasePath) 145 | return 146 | } 147 | fmt.Println("Local address:") 148 | // get ip address 149 | netInterfaces, _ := net.Interfaces() 150 | for i := 0; i < len(netInterfaces); i++ { 151 | if len(netInterfaces[i].Flags) > 2 && netInterfaces[i].Flags[0] == "up" && netInterfaces[i].Flags[1] != "loopback" { 152 | addrs := netInterfaces[i].Addrs 153 | for _, address := range addrs { 154 | IP := strings.Split(address.Addr, "/")[0] 155 | if strings.Contains(address.Addr, ".") { 156 | fmt.Println(Proto + IP + PortText + BasePath) 157 | } else if address.Addr[0:6] != "fe80::" { 158 | fmt.Println(Proto + "[" + IP + "]" + PortText + BasePath) 159 | } 160 | } 161 | } 162 | } 163 | resp, err := http.Get("https://api.ipify.org?format=text") 164 | if err == nil { 165 | defer resp.Body.Close() 166 | ip, err := io.ReadAll(resp.Body) 167 | if err == nil { 168 | fmt.Printf("\nGlobal address:\n%s%s%s%s\n", Proto, ip, PortText, BasePath) 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | //go:embed version 12 | var version string 13 | 14 | //go:embed name 15 | var name string 16 | 17 | type LogLevel string 18 | 19 | const ( 20 | Debug LogLevel = "debug" 21 | Info LogLevel = "info" 22 | Warn LogLevel = "warn" 23 | Error LogLevel = "error" 24 | ) 25 | 26 | func GetVersion() string { 27 | return strings.TrimSpace(version) 28 | } 29 | 30 | func GetName() string { 31 | return strings.TrimSpace(name) 32 | } 33 | 34 | func GetLogLevel() LogLevel { 35 | if IsDebug() { 36 | return Debug 37 | } 38 | logLevel := os.Getenv("SUI_LOG_LEVEL") 39 | if logLevel == "" { 40 | return Info 41 | } 42 | return LogLevel(logLevel) 43 | } 44 | 45 | func IsDebug() bool { 46 | return os.Getenv("SUI_DEBUG") == "true" 47 | } 48 | 49 | func GetDBFolderPath() string { 50 | dbFolderPath := os.Getenv("SUI_DB_FOLDER") 51 | if dbFolderPath == "" { 52 | dir, err := filepath.Abs(filepath.Dir(os.Args[0])) 53 | if err != nil { 54 | return "/usr/local/s-ui/db" 55 | } 56 | dbFolderPath = dir + "/db" 57 | } 58 | return dbFolderPath 59 | } 60 | 61 | func GetDBPath() string { 62 | return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName()) 63 | } 64 | -------------------------------------------------------------------------------- /config/name: -------------------------------------------------------------------------------- 1 | s-ui -------------------------------------------------------------------------------- /config/version: -------------------------------------------------------------------------------- 1 | 1.2.2 -------------------------------------------------------------------------------- /core/conntracker.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "s-ui/database/model" 7 | "sync" 8 | "time" 9 | 10 | "github.com/sagernet/sing-box/adapter" 11 | "github.com/sagernet/sing/common/atomic" 12 | "github.com/sagernet/sing/common/bufio" 13 | "github.com/sagernet/sing/common/network" 14 | ) 15 | 16 | type Counter struct { 17 | read *atomic.Int64 18 | write *atomic.Int64 19 | } 20 | 21 | type ConnTracker struct { 22 | access sync.Mutex 23 | createdAt time.Time 24 | inbounds map[string]Counter 25 | outbounds map[string]Counter 26 | users map[string]Counter 27 | } 28 | 29 | func NewConnTracker() *ConnTracker { 30 | return &ConnTracker{ 31 | createdAt: time.Now(), 32 | inbounds: make(map[string]Counter), 33 | outbounds: make(map[string]Counter), 34 | users: make(map[string]Counter), 35 | } 36 | } 37 | 38 | func (c *ConnTracker) getReadCounters(inbound string, outbound string, user string) ([]*atomic.Int64, []*atomic.Int64) { 39 | var readCounter []*atomic.Int64 40 | var writeCounter []*atomic.Int64 41 | c.access.Lock() 42 | if inbound != "" { 43 | readCounter = append(readCounter, c.loadOrCreateCounter(&c.inbounds, inbound).read) 44 | writeCounter = append(writeCounter, c.inbounds[inbound].write) 45 | } 46 | if outbound != "" { 47 | readCounter = append(readCounter, c.loadOrCreateCounter(&c.outbounds, outbound).read) 48 | writeCounter = append(writeCounter, c.outbounds[outbound].write) 49 | } 50 | if user != "" { 51 | readCounter = append(readCounter, c.loadOrCreateCounter(&c.users, user).read) 52 | writeCounter = append(writeCounter, c.users[user].write) 53 | } 54 | c.access.Unlock() 55 | return readCounter, writeCounter 56 | } 57 | 58 | func (c *ConnTracker) loadOrCreateCounter(obj *map[string]Counter, name string) Counter { 59 | counter, loaded := (*obj)[name] 60 | if loaded { 61 | return counter 62 | } 63 | counter = Counter{read: &atomic.Int64{}, write: &atomic.Int64{}} 64 | (*obj)[name] = counter 65 | return counter 66 | } 67 | 68 | func (c *ConnTracker) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { 69 | readCounter, writeCounter := c.getReadCounters(metadata.Inbound, matchOutbound.Tag(), metadata.User) 70 | return bufio.NewInt64CounterConn(conn, readCounter, writeCounter) 71 | } 72 | 73 | func (c *ConnTracker) RoutedPacketConnection(ctx context.Context, conn network.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) network.PacketConn { 74 | readCounter, writeCounter := c.getReadCounters(metadata.Inbound, matchOutbound.Tag(), metadata.User) 75 | return bufio.NewInt64CounterPacketConn(conn, readCounter, writeCounter) 76 | } 77 | 78 | func (c *ConnTracker) GetStats() *[]model.Stats { 79 | c.access.Lock() 80 | defer c.access.Unlock() 81 | 82 | dt := time.Now().Unix() 83 | 84 | s := []model.Stats{} 85 | for inbound, counter := range c.inbounds { 86 | down := counter.write.Swap(0) 87 | up := counter.read.Swap(0) 88 | if down > 0 || up > 0 { 89 | s = append(s, model.Stats{ 90 | DateTime: dt, 91 | Resource: "inbound", 92 | Tag: inbound, 93 | Direction: false, 94 | Traffic: down, 95 | }, model.Stats{ 96 | DateTime: dt, 97 | Resource: "inbound", 98 | Tag: inbound, 99 | Direction: true, 100 | Traffic: up, 101 | }) 102 | } 103 | } 104 | 105 | for outbound, counter := range c.outbounds { 106 | down := counter.write.Swap(0) 107 | up := counter.read.Swap(0) 108 | if down > 0 || up > 0 { 109 | s = append(s, model.Stats{ 110 | DateTime: dt, 111 | Resource: "outbound", 112 | Tag: outbound, 113 | Direction: false, 114 | Traffic: down, 115 | }, model.Stats{ 116 | DateTime: dt, 117 | Resource: "outbound", 118 | Tag: outbound, 119 | Direction: true, 120 | Traffic: up, 121 | }) 122 | } 123 | } 124 | 125 | for user, counter := range c.users { 126 | down := counter.write.Swap(0) 127 | up := counter.read.Swap(0) 128 | if down > 0 || up > 0 { 129 | s = append(s, model.Stats{ 130 | DateTime: dt, 131 | Resource: "user", 132 | Tag: user, 133 | Direction: false, 134 | Traffic: down, 135 | }, model.Stats{ 136 | DateTime: dt, 137 | Resource: "user", 138 | Tag: user, 139 | Direction: true, 140 | Traffic: up, 141 | }) 142 | } 143 | } 144 | return &s 145 | } 146 | -------------------------------------------------------------------------------- /core/endpoint.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "s-ui/logger" 5 | "s-ui/util/common" 6 | 7 | "github.com/sagernet/sing-box/adapter" 8 | "github.com/sagernet/sing-box/option" 9 | ) 10 | 11 | func (c *Core) AddInbound(config []byte) error { 12 | if !c.isRunning { 13 | return common.NewError("sing-box is not running") 14 | } 15 | var err error 16 | var inbound_config option.Inbound 17 | err = inbound_config.UnmarshalJSONContext(c.GetCtx(), config) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | err = inbound_manager.Create( 23 | c.GetCtx(), 24 | router, 25 | factory.NewLogger("inbound/"+inbound_config.Type+"["+inbound_config.Tag+"]"), 26 | inbound_config.Tag, 27 | inbound_config.Type, 28 | inbound_config.Options) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func (c *Core) RemoveInbound(tag string) error { 37 | if !c.isRunning { 38 | return common.NewError("sing-box is not running") 39 | } 40 | logger.Info("remove inbound: ", tag) 41 | return inbound_manager.Remove(tag) 42 | } 43 | 44 | func (c *Core) AddOutbound(config []byte) error { 45 | if !c.isRunning { 46 | return common.NewError("sing-box is not running") 47 | } 48 | var err error 49 | var outbound_config option.Outbound 50 | 51 | err = outbound_config.UnmarshalJSONContext(c.GetCtx(), config) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | outboundCtx := adapter.WithContext(c.GetCtx(), &adapter.InboundContext{ 57 | Outbound: outbound_config.Tag, 58 | }) 59 | 60 | err = outbound_manager.Create( 61 | outboundCtx, 62 | router, 63 | factory.NewLogger("outbound/"+outbound_config.Type+"["+outbound_config.Tag+"]"), 64 | outbound_config.Tag, 65 | outbound_config.Type, 66 | outbound_config.Options) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func (c *Core) RemoveOutbound(tag string) error { 75 | if !c.isRunning { 76 | return common.NewError("sing-box is not running") 77 | } 78 | logger.Info("remove outbound: ", tag) 79 | return outbound_manager.Remove(tag) 80 | } 81 | 82 | func (c *Core) AddEndpoint(config []byte) error { 83 | if !c.isRunning { 84 | return common.NewError("sing-box is not running") 85 | } 86 | var err error 87 | var endpoint_config option.Endpoint 88 | 89 | err = endpoint_config.UnmarshalJSONContext(c.GetCtx(), config) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | err = endpoint_manager.Create( 95 | c.GetCtx(), 96 | router, 97 | factory.NewLogger("endpoint/"+endpoint_config.Type+"["+endpoint_config.Tag+"]"), 98 | endpoint_config.Tag, 99 | endpoint_config.Type, 100 | endpoint_config.Options) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | return nil 106 | } 107 | 108 | func (c *Core) RemoveEndpoint(tag string) error { 109 | if !c.isRunning { 110 | return common.NewError("sing-box is not running") 111 | } 112 | logger.Info("remove endpoint: ", tag) 113 | return endpoint_manager.Remove(tag) 114 | } 115 | -------------------------------------------------------------------------------- /core/log.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | suiLog "s-ui/logger" 8 | 9 | "github.com/sagernet/sing-box/log" 10 | "github.com/sagernet/sing/common" 11 | F "github.com/sagernet/sing/common/format" 12 | "github.com/sagernet/sing/common/observable" 13 | "github.com/sagernet/sing/service/filemanager" 14 | ) 15 | 16 | type PlatformWriter struct{} 17 | 18 | func (p PlatformWriter) DisableColors() bool { 19 | return true 20 | } 21 | func (p PlatformWriter) WriteMessage(level log.Level, message string) { 22 | switch level { 23 | case log.LevelInfo: 24 | suiLog.Info(message) 25 | case log.LevelWarn: 26 | suiLog.Warning(message) 27 | case log.LevelPanic: 28 | case log.LevelFatal: 29 | case log.LevelError: 30 | suiLog.Error(message) 31 | default: 32 | suiLog.Debug(message) 33 | } 34 | } 35 | 36 | func NewFactory(options log.Options) (log.Factory, error) { 37 | logOptions := options.Options 38 | 39 | if logOptions.Disabled { 40 | return log.NewNOPFactory(), nil 41 | } 42 | 43 | var logWriter io.Writer 44 | var logFilePath string 45 | 46 | switch logOptions.Output { 47 | case "": 48 | logWriter = options.DefaultWriter 49 | if logWriter == nil { 50 | logWriter = os.Stderr 51 | } 52 | case "stderr": 53 | logWriter = os.Stderr 54 | case "stdout": 55 | logWriter = os.Stdout 56 | default: 57 | logFilePath = logOptions.Output 58 | } 59 | logFormatter := log.Formatter{ 60 | BaseTime: options.BaseTime, 61 | DisableColors: logOptions.DisableColor || logFilePath != "", 62 | DisableTimestamp: !logOptions.Timestamp && logFilePath != "", 63 | FullTimestamp: logOptions.Timestamp, 64 | TimestampFormat: "-0700 2006-01-02 15:04:05", 65 | } 66 | factory := NewDefaultFactory( 67 | options.Context, 68 | logFormatter, 69 | logWriter, 70 | logFilePath, 71 | ) 72 | if logOptions.Level != "" { 73 | logLevel, err := log.ParseLevel(logOptions.Level) 74 | if err != nil { 75 | return nil, common.Error("parse log level", err) 76 | } 77 | factory.SetLevel(logLevel) 78 | } else { 79 | factory.SetLevel(log.LevelTrace) 80 | } 81 | return factory, nil 82 | } 83 | 84 | var _ log.Factory = (*defaultFactory)(nil) 85 | 86 | type defaultFactory struct { 87 | ctx context.Context 88 | formatter log.Formatter 89 | writer io.Writer 90 | file *os.File 91 | filePath string 92 | level log.Level 93 | subscriber *observable.Subscriber[log.Entry] 94 | observer *observable.Observer[log.Entry] 95 | } 96 | 97 | func NewDefaultFactory( 98 | ctx context.Context, 99 | formatter log.Formatter, 100 | writer io.Writer, 101 | filePath string, 102 | ) log.ObservableFactory { 103 | factory := &defaultFactory{ 104 | ctx: ctx, 105 | formatter: formatter, 106 | writer: writer, 107 | filePath: filePath, 108 | level: log.LevelTrace, 109 | subscriber: observable.NewSubscriber[log.Entry](128), 110 | } 111 | return factory 112 | } 113 | 114 | func (f *defaultFactory) Start() error { 115 | if f.filePath != "" { 116 | logFile, err := filemanager.OpenFile(f.ctx, f.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) 117 | if err != nil { 118 | return err 119 | } 120 | f.writer = logFile 121 | f.file = logFile 122 | } 123 | return nil 124 | } 125 | 126 | func (f *defaultFactory) Close() error { 127 | return common.Close( 128 | common.PtrOrNil(f.file), 129 | f.subscriber, 130 | ) 131 | } 132 | 133 | func (f *defaultFactory) Level() log.Level { 134 | return f.level 135 | } 136 | 137 | func (f *defaultFactory) SetLevel(level log.Level) { 138 | f.level = level 139 | } 140 | 141 | func (f *defaultFactory) Logger() log.ContextLogger { 142 | return f.NewLogger("") 143 | } 144 | 145 | func (f *defaultFactory) NewLogger(tag string) log.ContextLogger { 146 | return &observableLogger{f, tag} 147 | } 148 | 149 | func (f *defaultFactory) Subscribe() (subscription observable.Subscription[log.Entry], done <-chan struct{}, err error) { 150 | return f.observer.Subscribe() 151 | } 152 | 153 | func (f *defaultFactory) UnSubscribe(sub observable.Subscription[log.Entry]) { 154 | f.observer.UnSubscribe(sub) 155 | } 156 | 157 | type observableLogger struct { 158 | *defaultFactory 159 | tag string 160 | } 161 | 162 | func (l *observableLogger) Log(ctx context.Context, level log.Level, args []any) { 163 | level = log.OverrideLevelFromContext(level, ctx) 164 | if level > l.level { 165 | return 166 | } 167 | msg := F.ToString(args...) 168 | switch level { 169 | case log.LevelInfo: 170 | suiLog.Info(l.tag, msg) 171 | case log.LevelWarn: 172 | suiLog.Warning(l.tag, msg) 173 | case log.LevelPanic: 174 | case log.LevelFatal: 175 | case log.LevelError: 176 | suiLog.Error(l.tag, msg) 177 | default: 178 | suiLog.Debug(l.tag, msg) 179 | } 180 | } 181 | 182 | func (l *observableLogger) Trace(args ...any) { 183 | l.TraceContext(context.Background(), args...) 184 | } 185 | 186 | func (l *observableLogger) Debug(args ...any) { 187 | l.DebugContext(context.Background(), args...) 188 | } 189 | 190 | func (l *observableLogger) Info(args ...any) { 191 | l.InfoContext(context.Background(), args...) 192 | } 193 | 194 | func (l *observableLogger) Warn(args ...any) { 195 | l.WarnContext(context.Background(), args...) 196 | } 197 | 198 | func (l *observableLogger) Error(args ...any) { 199 | l.ErrorContext(context.Background(), args...) 200 | } 201 | 202 | func (l *observableLogger) Fatal(args ...any) { 203 | l.FatalContext(context.Background(), args...) 204 | } 205 | 206 | func (l *observableLogger) Panic(args ...any) { 207 | l.PanicContext(context.Background(), args...) 208 | } 209 | 210 | func (l *observableLogger) TraceContext(ctx context.Context, args ...any) { 211 | l.Log(ctx, log.LevelTrace, args) 212 | } 213 | 214 | func (l *observableLogger) DebugContext(ctx context.Context, args ...any) { 215 | l.Log(ctx, log.LevelDebug, args) 216 | } 217 | 218 | func (l *observableLogger) InfoContext(ctx context.Context, args ...any) { 219 | l.Log(ctx, log.LevelInfo, args) 220 | } 221 | 222 | func (l *observableLogger) WarnContext(ctx context.Context, args ...any) { 223 | l.Log(ctx, log.LevelWarn, args) 224 | } 225 | 226 | func (l *observableLogger) ErrorContext(ctx context.Context, args ...any) { 227 | l.Log(ctx, log.LevelError, args) 228 | } 229 | 230 | func (l *observableLogger) FatalContext(ctx context.Context, args ...any) { 231 | l.Log(ctx, log.LevelFatal, args) 232 | } 233 | 234 | func (l *observableLogger) PanicContext(ctx context.Context, args ...any) { 235 | l.Log(ctx, log.LevelPanic, args) 236 | } 237 | -------------------------------------------------------------------------------- /core/main.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "s-ui/logger" 6 | 7 | sb "github.com/sagernet/sing-box" 8 | "github.com/sagernet/sing-box/adapter" 9 | _ "github.com/sagernet/sing-box/experimental/clashapi" 10 | _ "github.com/sagernet/sing-box/experimental/v2rayapi" 11 | "github.com/sagernet/sing-box/log" 12 | "github.com/sagernet/sing-box/option" 13 | _ "github.com/sagernet/sing-box/transport/v2rayquic" 14 | _ "github.com/sagernet/sing-dns/quic" 15 | "github.com/sagernet/sing/service" 16 | ) 17 | 18 | var ( 19 | globalCtx context.Context 20 | inbound_manager adapter.InboundManager 21 | outbound_manager adapter.OutboundManager 22 | endpoint_manager adapter.EndpointManager 23 | router adapter.Router 24 | connTracker *ConnTracker 25 | factory log.Factory 26 | ) 27 | 28 | type Core struct { 29 | isRunning bool 30 | instance *Box 31 | } 32 | 33 | func NewCore() *Core { 34 | globalCtx = context.Background() 35 | globalCtx = sb.Context(globalCtx, inboundRegistry(), outboundRegistry(), EndpointRegistry()) 36 | return &Core{ 37 | isRunning: false, 38 | instance: nil, 39 | } 40 | } 41 | 42 | func (c *Core) GetCtx() context.Context { 43 | return globalCtx 44 | } 45 | 46 | func (c *Core) GetInstance() *Box { 47 | return c.instance 48 | } 49 | 50 | func (c *Core) Start(sbConfig []byte) error { 51 | var opt option.Options 52 | err := opt.UnmarshalJSONContext(globalCtx, sbConfig) 53 | if err != nil { 54 | logger.Error("Unmarshal config err:", err.Error()) 55 | } 56 | 57 | c.instance, err = NewBox(Options{ 58 | Context: globalCtx, 59 | Options: opt, 60 | }) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | err = c.instance.Start() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | globalCtx = service.ContextWith(globalCtx, c) 71 | inbound_manager = service.FromContext[adapter.InboundManager](globalCtx) 72 | outbound_manager = service.FromContext[adapter.OutboundManager](globalCtx) 73 | endpoint_manager = service.FromContext[adapter.EndpointManager](globalCtx) 74 | router = service.FromContext[adapter.Router](globalCtx) 75 | 76 | c.isRunning = true 77 | return nil 78 | } 79 | 80 | func (c *Core) Stop() error { 81 | if c.isRunning { 82 | c.isRunning = false 83 | return c.instance.Close() 84 | } 85 | return nil 86 | } 87 | 88 | func (c *Core) IsRunning() bool { 89 | return c.isRunning 90 | } 91 | -------------------------------------------------------------------------------- /core/register.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "github.com/sagernet/sing-box/adapter/endpoint" 5 | "github.com/sagernet/sing-box/adapter/inbound" 6 | "github.com/sagernet/sing-box/adapter/outbound" 7 | "github.com/sagernet/sing-box/protocol/block" 8 | "github.com/sagernet/sing-box/protocol/direct" 9 | "github.com/sagernet/sing-box/protocol/dns" 10 | "github.com/sagernet/sing-box/protocol/group" 11 | "github.com/sagernet/sing-box/protocol/http" 12 | "github.com/sagernet/sing-box/protocol/hysteria" 13 | "github.com/sagernet/sing-box/protocol/hysteria2" 14 | "github.com/sagernet/sing-box/protocol/mixed" 15 | "github.com/sagernet/sing-box/protocol/naive" 16 | _ "github.com/sagernet/sing-box/protocol/naive/quic" 17 | "github.com/sagernet/sing-box/protocol/redirect" 18 | "github.com/sagernet/sing-box/protocol/shadowsocks" 19 | "github.com/sagernet/sing-box/protocol/shadowtls" 20 | "github.com/sagernet/sing-box/protocol/socks" 21 | "github.com/sagernet/sing-box/protocol/ssh" 22 | "github.com/sagernet/sing-box/protocol/tor" 23 | "github.com/sagernet/sing-box/protocol/trojan" 24 | "github.com/sagernet/sing-box/protocol/tuic" 25 | "github.com/sagernet/sing-box/protocol/tun" 26 | "github.com/sagernet/sing-box/protocol/vless" 27 | "github.com/sagernet/sing-box/protocol/vmess" 28 | "github.com/sagernet/sing-box/protocol/wireguard" 29 | _ "github.com/sagernet/sing-box/transport/v2rayquic" 30 | _ "github.com/sagernet/sing-dns/quic" 31 | ) 32 | 33 | func inboundRegistry() *inbound.Registry { 34 | registry := inbound.NewRegistry() 35 | 36 | tun.RegisterInbound(registry) 37 | redirect.RegisterRedirect(registry) 38 | redirect.RegisterTProxy(registry) 39 | direct.RegisterInbound(registry) 40 | 41 | socks.RegisterInbound(registry) 42 | http.RegisterInbound(registry) 43 | mixed.RegisterInbound(registry) 44 | 45 | shadowsocks.RegisterInbound(registry) 46 | vmess.RegisterInbound(registry) 47 | trojan.RegisterInbound(registry) 48 | naive.RegisterInbound(registry) 49 | shadowtls.RegisterInbound(registry) 50 | vless.RegisterInbound(registry) 51 | 52 | hysteria.RegisterInbound(registry) 53 | tuic.RegisterInbound(registry) 54 | hysteria2.RegisterInbound(registry) 55 | 56 | return registry 57 | } 58 | 59 | func outboundRegistry() *outbound.Registry { 60 | registry := outbound.NewRegistry() 61 | 62 | direct.RegisterOutbound(registry) 63 | 64 | block.RegisterOutbound(registry) 65 | dns.RegisterOutbound(registry) 66 | 67 | group.RegisterSelector(registry) 68 | group.RegisterURLTest(registry) 69 | 70 | socks.RegisterOutbound(registry) 71 | http.RegisterOutbound(registry) 72 | shadowsocks.RegisterOutbound(registry) 73 | vmess.RegisterOutbound(registry) 74 | trojan.RegisterOutbound(registry) 75 | tor.RegisterOutbound(registry) 76 | ssh.RegisterOutbound(registry) 77 | shadowtls.RegisterOutbound(registry) 78 | vless.RegisterOutbound(registry) 79 | 80 | hysteria.RegisterOutbound(registry) 81 | tuic.RegisterOutbound(registry) 82 | hysteria2.RegisterOutbound(registry) 83 | wireguard.RegisterOutbound(registry) 84 | 85 | return registry 86 | } 87 | 88 | func EndpointRegistry() *endpoint.Registry { 89 | registry := endpoint.NewRegistry() 90 | 91 | wireguard.RegisterEndpoint(registry) 92 | 93 | return registry 94 | } 95 | -------------------------------------------------------------------------------- /cronjob/checkCoreJob.go: -------------------------------------------------------------------------------- 1 | package cronjob 2 | 3 | import ( 4 | "s-ui/service" 5 | ) 6 | 7 | type CheckCoreJob struct { 8 | service.ConfigService 9 | } 10 | 11 | func NewCheckCoreJob() *CheckCoreJob { 12 | return &CheckCoreJob{} 13 | } 14 | 15 | func (s *CheckCoreJob) Run() { 16 | s.ConfigService.StartCore("") 17 | } 18 | -------------------------------------------------------------------------------- /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 | // Start core if it is not running 29 | c.cron.AddJob("@every 5s", NewCheckCoreJob()) 30 | }() 31 | 32 | return nil 33 | } 34 | 35 | func (c *CronJob) Stop() { 36 | if c.cron != nil { 37 | c.cron.Stop() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.ClientService 10 | } 11 | 12 | func NewDepleteJob() *DepleteJob { 13 | return new(DepleteJob) 14 | } 15 | 16 | func (s *DepleteJob) Run() { 17 | err := s.ClientService.DepleteClients() 18 | if err != nil { 19 | logger.Warning("Disable depleted users failed: ", err) 20 | return 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /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.StatsService 10 | } 11 | 12 | func NewStatsJob() *StatsJob { 13 | return &StatsJob{} 14 | } 15 | 16 | func (s *StatsJob) Run() { 17 | err := s.StatsService.SaveStats() 18 | if err != nil { 19 | logger.Warning("Get stats failed: ", err) 20 | return 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /database/backup.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "mime/multipart" 8 | "os" 9 | "path/filepath" 10 | "s-ui/cmd/migration" 11 | "s-ui/config" 12 | "s-ui/database/model" 13 | "s-ui/logger" 14 | "s-ui/util/common" 15 | "strings" 16 | "syscall" 17 | "time" 18 | 19 | "gorm.io/driver/sqlite" 20 | "gorm.io/gorm" 21 | ) 22 | 23 | func GetDb(exclude string) ([]byte, error) { 24 | exclude_changes, exclude_stats := false, false 25 | for _, table := range strings.Split(exclude, ",") { 26 | if table == "changes" { 27 | exclude_changes = true 28 | } else if table == "stats" { 29 | exclude_stats = true 30 | } 31 | } 32 | 33 | dir, err := filepath.Abs(filepath.Dir(os.Args[0])) 34 | if err != nil { 35 | return nil, err 36 | } 37 | dbPath := dir + config.GetName() + "_" + time.Now().Format("20060102-200203") + ".db" 38 | 39 | backupDb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) 40 | if err != nil { 41 | return nil, err 42 | } 43 | defer os.Remove(dbPath) 44 | 45 | err = backupDb.AutoMigrate( 46 | &model.Setting{}, 47 | &model.Tls{}, 48 | &model.Inbound{}, 49 | &model.Outbound{}, 50 | &model.Endpoint{}, 51 | &model.User{}, 52 | &model.Stats{}, 53 | &model.Client{}, 54 | &model.Changes{}, 55 | ) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | var settings []model.Setting 61 | var tls []model.Tls 62 | var inbound []model.Inbound 63 | var outbound []model.Outbound 64 | var endpoint []model.Endpoint 65 | var users []model.User 66 | var clients []model.Client 67 | var stats []model.Stats 68 | var changes []model.Changes 69 | 70 | // Perform scans and handle errors 71 | if err := db.Model(&model.Setting{}).Scan(&settings).Error; err != nil { 72 | return nil, err 73 | } else if len(settings) > 0 { 74 | if err := backupDb.Save(settings).Error; err != nil { 75 | return nil, err 76 | } 77 | } 78 | if err := db.Model(&model.Tls{}).Scan(&tls).Error; err != nil { 79 | return nil, err 80 | } else if len(tls) > 0 { 81 | if err := backupDb.Save(tls).Error; err != nil { 82 | return nil, err 83 | } 84 | } 85 | if err := db.Model(&model.Inbound{}).Scan(&inbound).Error; err != nil { 86 | return nil, err 87 | } else if len(inbound) > 0 { 88 | if err := backupDb.Save(inbound).Error; err != nil { 89 | return nil, err 90 | } 91 | } 92 | if err := db.Model(&model.Outbound{}).Scan(&outbound).Error; err != nil { 93 | return nil, err 94 | } else if len(outbound) > 0 { 95 | if err := backupDb.Save(outbound).Error; err != nil { 96 | return nil, err 97 | } 98 | } 99 | if err := db.Model(&model.Endpoint{}).Scan(&endpoint).Error; err != nil { 100 | return nil, err 101 | } else if len(endpoint) > 0 { 102 | if err := backupDb.Save(endpoint).Error; err != nil { 103 | return nil, err 104 | } 105 | } 106 | if err := db.Model(&model.User{}).Scan(&users).Error; err != nil { 107 | return nil, err 108 | } else if len(users) > 0 { 109 | if err := backupDb.Save(users).Error; err != nil { 110 | return nil, err 111 | } 112 | } 113 | if err := db.Model(&model.Client{}).Scan(&clients).Error; err != nil { 114 | return nil, err 115 | } else if len(clients) > 0 { 116 | if err := backupDb.Save(clients).Error; err != nil { 117 | return nil, err 118 | } 119 | } 120 | 121 | if !exclude_stats { 122 | if err := db.Model(&model.Stats{}).Scan(&stats).Error; err != nil { 123 | return nil, err 124 | } 125 | if len(stats) > 0 { 126 | if err := backupDb.Save(stats).Error; err != nil { 127 | return nil, err 128 | } 129 | } 130 | } 131 | if !exclude_changes { 132 | if err := db.Model(&model.Changes{}).Scan(&changes).Error; err != nil { 133 | return nil, err 134 | } 135 | if len(changes) > 0 { 136 | if err := backupDb.Save(changes).Error; err != nil { 137 | return nil, err 138 | } 139 | } 140 | } 141 | 142 | // Update WAL 143 | err = backupDb.Exec("PRAGMA wal_checkpoint;").Error 144 | if err != nil { 145 | return nil, err 146 | } 147 | 148 | bdb, _ := backupDb.DB() 149 | bdb.Close() 150 | 151 | // Open the file for reading 152 | file, err := os.Open(dbPath) 153 | if err != nil { 154 | return nil, err 155 | } 156 | defer file.Close() 157 | 158 | // Read the file contents 159 | fileContents, err := io.ReadAll(file) 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | return fileContents, nil 165 | } 166 | 167 | func ImportDB(file multipart.File) error { 168 | // Check if the file is a SQLite database 169 | isValidDb, err := IsSQLiteDB(file) 170 | if err != nil { 171 | return common.NewErrorf("Error checking db file format: %v", err) 172 | } 173 | if !isValidDb { 174 | return common.NewError("Invalid db file format") 175 | } 176 | 177 | // Reset the file reader to the beginning 178 | _, err = file.Seek(0, 0) 179 | if err != nil { 180 | return common.NewErrorf("Error resetting file reader: %v", err) 181 | } 182 | 183 | // Save the file as temporary file 184 | tempPath := fmt.Sprintf("%s.temp", config.GetDBPath()) 185 | // Remove the existing fallback file (if any) before creating one 186 | _, err = os.Stat(tempPath) 187 | if err == nil { 188 | errRemove := os.Remove(tempPath) 189 | if errRemove != nil { 190 | return common.NewErrorf("Error removing existing temporary db file: %v", errRemove) 191 | } 192 | } 193 | // Create the temporary file 194 | tempFile, err := os.Create(tempPath) 195 | if err != nil { 196 | return common.NewErrorf("Error creating temporary db file: %v", err) 197 | } 198 | defer tempFile.Close() 199 | 200 | // Remove temp file before returning 201 | defer os.Remove(tempPath) 202 | 203 | // Close old DB 204 | old_db, _ := db.DB() 205 | old_db.Close() 206 | 207 | // Save uploaded file to temporary file 208 | _, err = io.Copy(tempFile, file) 209 | if err != nil { 210 | return common.NewErrorf("Error saving db: %v", err) 211 | } 212 | 213 | // Check if we can init db or not 214 | newDb, err := gorm.Open(sqlite.Open(tempPath), &gorm.Config{}) 215 | if err != nil { 216 | return common.NewErrorf("Error checking db: %v", err) 217 | } 218 | newDb_db, _ := newDb.DB() 219 | newDb_db.Close() 220 | 221 | // Backup the current database for fallback 222 | fallbackPath := fmt.Sprintf("%s.backup", config.GetDBPath()) 223 | // Remove the existing fallback file (if any) 224 | _, err = os.Stat(fallbackPath) 225 | if err == nil { 226 | errRemove := os.Remove(fallbackPath) 227 | if errRemove != nil { 228 | return common.NewErrorf("Error removing existing fallback db file: %v", errRemove) 229 | } 230 | } 231 | // Move the current database to the fallback location 232 | err = os.Rename(config.GetDBPath(), fallbackPath) 233 | if err != nil { 234 | return common.NewErrorf("Error backing up temporary db file: %v", err) 235 | } 236 | 237 | // Remove the temporary file before returning 238 | defer os.Remove(fallbackPath) 239 | 240 | // Move temp to DB path 241 | err = os.Rename(tempPath, config.GetDBPath()) 242 | if err != nil { 243 | errRename := os.Rename(fallbackPath, config.GetDBPath()) 244 | if errRename != nil { 245 | return common.NewErrorf("Error moving db file and restoring fallback: %v", errRename) 246 | } 247 | return common.NewErrorf("Error moving db file: %v", err) 248 | } 249 | 250 | // Migrate DB 251 | migration.MigrateDb() 252 | err = InitDB(config.GetDBPath()) 253 | if err != nil { 254 | errRename := os.Rename(fallbackPath, config.GetDBPath()) 255 | if errRename != nil { 256 | return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename) 257 | } 258 | return common.NewErrorf("Error migrating db: %v", err) 259 | } 260 | 261 | // Restart app 262 | err = SendSighup() 263 | if err != nil { 264 | return common.NewErrorf("Error restarting app: %v", err) 265 | } 266 | 267 | return nil 268 | } 269 | 270 | func IsSQLiteDB(file io.Reader) (bool, error) { 271 | signature := []byte("SQLite format 3\x00") 272 | buf := make([]byte, len(signature)) 273 | _, err := file.Read(buf) 274 | if err != nil { 275 | return false, err 276 | } 277 | return bytes.Equal(buf, signature), nil 278 | } 279 | 280 | func SendSighup() error { 281 | // Get the current process 282 | process, err := os.FindProcess(os.Getpid()) 283 | if err != nil { 284 | return err 285 | } 286 | 287 | // Send SIGHUP to the current process 288 | go func() { 289 | time.Sleep(3 * time.Second) 290 | err := process.Signal(syscall.SIGHUP) 291 | if err != nil { 292 | logger.Error("send signal SIGHUP failed:", err) 293 | } 294 | }() 295 | return nil 296 | } 297 | -------------------------------------------------------------------------------- /database/db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | "s-ui/config" 8 | "s-ui/database/model" 9 | 10 | "gorm.io/driver/sqlite" 11 | "gorm.io/gorm" 12 | "gorm.io/gorm/logger" 13 | ) 14 | 15 | var db *gorm.DB 16 | 17 | func initUser() error { 18 | var count int64 19 | err := db.Model(&model.User{}).Count(&count).Error 20 | if err != nil { 21 | return err 22 | } 23 | if count == 0 { 24 | user := &model.User{ 25 | Username: "admin", 26 | Password: "admin", 27 | } 28 | return db.Create(user).Error 29 | } 30 | return nil 31 | } 32 | 33 | func OpenDB(dbPath string) error { 34 | dir := path.Dir(dbPath) 35 | err := os.MkdirAll(dir, 01740) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | var gormLogger logger.Interface 41 | 42 | if config.IsDebug() { 43 | gormLogger = logger.Default 44 | } else { 45 | gormLogger = logger.Discard 46 | } 47 | 48 | c := &gorm.Config{ 49 | Logger: gormLogger, 50 | } 51 | db, err = gorm.Open(sqlite.Open(dbPath), c) 52 | 53 | if config.IsDebug() { 54 | db = db.Debug() 55 | } 56 | return err 57 | } 58 | 59 | func InitDB(dbPath string) error { 60 | err := OpenDB(dbPath) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | // Default Outbounds 66 | if !db.Migrator().HasTable(&model.Outbound{}) { 67 | db.Migrator().CreateTable(&model.Outbound{}) 68 | defaultOutbound := []model.Outbound{ 69 | {Type: "direct", Tag: "direct", Options: json.RawMessage(`{}`)}, 70 | } 71 | db.Create(&defaultOutbound) 72 | } 73 | 74 | err = db.AutoMigrate( 75 | &model.Setting{}, 76 | &model.Tls{}, 77 | &model.Inbound{}, 78 | &model.Outbound{}, 79 | &model.Endpoint{}, 80 | &model.User{}, 81 | &model.Tokens{}, 82 | &model.Stats{}, 83 | &model.Client{}, 84 | &model.Changes{}, 85 | ) 86 | if err != nil { 87 | return err 88 | } 89 | err = initUser() 90 | if err != nil { 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func GetDB() *gorm.DB { 98 | return db 99 | } 100 | 101 | func IsNotFound(err error) bool { 102 | return err == gorm.ErrRecordNotFound 103 | } 104 | -------------------------------------------------------------------------------- /database/model/endpoints.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Endpoint struct { 8 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 9 | Type string `json:"type" form:"type"` 10 | Tag string `json:"tag" form:"tag" gorm:"unique"` 11 | Options json.RawMessage `json:"-" form:"-"` 12 | Ext json.RawMessage `json:"ext" form:"ext"` 13 | } 14 | 15 | func (o *Endpoint) UnmarshalJSON(data []byte) error { 16 | var err error 17 | var raw map[string]interface{} 18 | if err = json.Unmarshal(data, &raw); err != nil { 19 | return err 20 | } 21 | 22 | // Extract fixed fields and store the rest in Options 23 | if val, exists := raw["id"].(float64); exists { 24 | o.Id = uint(val) 25 | } 26 | delete(raw, "id") 27 | o.Type, _ = raw["type"].(string) 28 | delete(raw, "type") 29 | o.Tag = raw["tag"].(string) 30 | delete(raw, "tag") 31 | o.Ext, _ = json.MarshalIndent(raw["ext"], "", " ") 32 | delete(raw, "ext") 33 | 34 | // Remaining fields 35 | o.Options, err = json.MarshalIndent(raw, "", " ") 36 | return err 37 | } 38 | 39 | // MarshalJSON customizes marshalling 40 | func (o Endpoint) MarshalJSON() ([]byte, error) { 41 | // Combine fixed fields and dynamic fields into one map 42 | combined := make(map[string]interface{}) 43 | switch o.Type { 44 | case "warp": 45 | combined["type"] = "wireguard" 46 | default: 47 | combined["type"] = o.Type 48 | } 49 | combined["tag"] = o.Tag 50 | 51 | if o.Options != nil { 52 | var restFields map[string]json.RawMessage 53 | if err := json.Unmarshal(o.Options, &restFields); err != nil { 54 | return nil, err 55 | } 56 | 57 | for k, v := range restFields { 58 | combined[k] = v 59 | } 60 | } 61 | 62 | return json.Marshal(combined) 63 | } 64 | -------------------------------------------------------------------------------- /database/model/inbounds.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Inbound struct { 8 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 9 | Type string `json:"type" form:"type"` 10 | Tag string `json:"tag" form:"tag" gorm:"unique"` 11 | 12 | // Foreign key to tls table 13 | TlsId uint `json:"tls_id" form:"tls_id"` 14 | Tls *Tls `json:"tls" form:"tls" gorm:"foreignKey:TlsId;references:Id"` 15 | 16 | Addrs json.RawMessage `json:"addrs" form:"addrs"` 17 | OutJson json.RawMessage `json:"out_json" form:"out_json"` 18 | Options json.RawMessage `json:"-" form:"-"` 19 | } 20 | 21 | func (i *Inbound) UnmarshalJSON(data []byte) error { 22 | var err error 23 | var raw map[string]interface{} 24 | if err = json.Unmarshal(data, &raw); err != nil { 25 | return err 26 | } 27 | 28 | // Extract fixed fields and store the rest in Options 29 | if val, exists := raw["id"].(float64); exists { 30 | i.Id = uint(val) 31 | } 32 | delete(raw, "id") 33 | i.Type, _ = raw["type"].(string) 34 | delete(raw, "type") 35 | i.Tag, _ = raw["tag"].(string) 36 | delete(raw, "tag") 37 | 38 | // TlsId 39 | if val, exists := raw["tls_id"].(float64); exists { 40 | i.TlsId = uint(val) 41 | } 42 | delete(raw, "tls_id") 43 | delete(raw, "tls") 44 | delete(raw, "users") 45 | 46 | // Addrs 47 | i.Addrs, _ = json.MarshalIndent(raw["addrs"], "", " ") 48 | delete(raw, "addrs") 49 | 50 | // OutJson 51 | i.OutJson, _ = json.MarshalIndent(raw["out_json"], "", " ") 52 | delete(raw, "out_json") 53 | 54 | // Remaining fields 55 | i.Options, err = json.MarshalIndent(raw, "", " ") 56 | return err 57 | } 58 | 59 | // MarshalJSON customizes marshalling 60 | func (i Inbound) MarshalJSON() ([]byte, error) { 61 | // Combine fixed fields and dynamic fields into one map 62 | combined := make(map[string]interface{}) 63 | combined["type"] = i.Type 64 | combined["tag"] = i.Tag 65 | if i.Tls != nil { 66 | combined["tls"] = i.Tls.Server 67 | } 68 | 69 | if i.Options != nil { 70 | var restFields map[string]json.RawMessage 71 | if err := json.Unmarshal(i.Options, &restFields); err != nil { 72 | return nil, err 73 | } 74 | 75 | for k, v := range restFields { 76 | combined[k] = v 77 | } 78 | } 79 | 80 | return json.Marshal(combined) 81 | } 82 | 83 | func (i Inbound) MarshalFull() (*map[string]interface{}, error) { 84 | combined := make(map[string]interface{}) 85 | combined["id"] = i.Id 86 | combined["type"] = i.Type 87 | combined["tag"] = i.Tag 88 | combined["tls_id"] = i.TlsId 89 | combined["addrs"] = i.Addrs 90 | combined["out_json"] = i.OutJson 91 | 92 | if i.Options != nil { 93 | var restFields map[string]interface{} 94 | if err := json.Unmarshal(i.Options, &restFields); err != nil { 95 | return nil, err 96 | } 97 | 98 | for k, v := range restFields { 99 | combined[k] = v 100 | } 101 | } 102 | return &combined, nil 103 | } 104 | -------------------------------------------------------------------------------- /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 | Server json.RawMessage `json:"server" form:"server"` 15 | Client json.RawMessage `json:"client" form:"client"` 16 | } 17 | 18 | type User struct { 19 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 20 | Username string `json:"username" form:"username"` 21 | Password string `json:"password" form:"password"` 22 | LastLogins string `json:"lastLogin"` 23 | } 24 | 25 | type Client struct { 26 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 27 | Enable bool `json:"enable" form:"enable"` 28 | Name string `json:"name" form:"name"` 29 | Config json.RawMessage `json:"config,omitempty" form:"config"` 30 | Inbounds json.RawMessage `json:"inbounds" form:"inbounds"` 31 | Links json.RawMessage `json:"links,omitempty" form:"links"` 32 | Volume int64 `json:"volume" form:"volume"` 33 | Expiry int64 `json:"expiry" form:"expiry"` 34 | Down int64 `json:"down" form:"down"` 35 | Up int64 `json:"up" form:"up"` 36 | Desc string `json:"desc" form:"desc"` 37 | Group string `json:"group" form:"group"` 38 | } 39 | 40 | type Stats struct { 41 | Id uint64 `json:"id" gorm:"primaryKey;autoIncrement"` 42 | DateTime int64 `json:"dateTime"` 43 | Resource string `json:"resource"` 44 | Tag string `json:"tag"` 45 | Direction bool `json:"direction"` 46 | Traffic int64 `json:"traffic"` 47 | } 48 | 49 | type Changes struct { 50 | Id uint64 `json:"id" gorm:"primaryKey;autoIncrement"` 51 | DateTime int64 `json:"dateTime"` 52 | Actor string `json:"actor"` 53 | Key string `json:"key"` 54 | Action string `json:"action"` 55 | Obj json.RawMessage `json:"obj"` 56 | } 57 | 58 | type Tokens struct { 59 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 60 | Desc string `json:"desc" form:"desc"` 61 | Token string `json:"token" form:"token"` 62 | Expiry int64 `json:"expiry" form:"expiry"` 63 | UserId uint `json:"userId" form:"userId"` 64 | User *User `json:"user" gorm:"foreignKey:UserId;references:Id"` 65 | } 66 | -------------------------------------------------------------------------------- /database/model/outbounds.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "encoding/json" 4 | 5 | type Outbound struct { 6 | Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 7 | Type string `json:"type" form:"type"` 8 | Tag string `json:"tag" form:"tag" gorm:"unique"` 9 | Options json.RawMessage `json:"-" form:"-"` 10 | } 11 | 12 | func (o *Outbound) UnmarshalJSON(data []byte) error { 13 | var err error 14 | var raw map[string]interface{} 15 | if err = json.Unmarshal(data, &raw); err != nil { 16 | return err 17 | } 18 | 19 | // Extract fixed fields and store the rest in Options 20 | if val, exists := raw["id"].(float64); exists { 21 | o.Id = uint(val) 22 | } 23 | delete(raw, "id") 24 | o.Type, _ = raw["type"].(string) 25 | delete(raw, "type") 26 | o.Tag = raw["tag"].(string) 27 | delete(raw, "tag") 28 | 29 | // Remaining fields 30 | o.Options, err = json.MarshalIndent(raw, "", " ") 31 | return err 32 | } 33 | 34 | // MarshalJSON customizes marshalling 35 | func (o Outbound) MarshalJSON() ([]byte, error) { 36 | // Combine fixed fields and dynamic fields into one map 37 | combined := make(map[string]interface{}) 38 | combined["type"] = o.Type 39 | combined["tag"] = o.Tag 40 | 41 | if o.Options != nil { 42 | var restFields map[string]json.RawMessage 43 | if err := json.Unmarshal(o.Options, &restFields); err != nil { 44 | return nil, err 45 | } 46 | 47 | for k, v := range restFields { 48 | combined[k] = v 49 | } 50 | } 51 | 52 | return json.Marshal(combined) 53 | } 54 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | s-ui: 4 | image: alireza7/s-ui 5 | container_name: s-ui 6 | hostname: "s-ui" 7 | volumes: 8 | - "./db:/app/db" 9 | - "./cert:/app/cert" 10 | tty: true 11 | restart: unless-stopped 12 | ports: 13 | - "2095:2095" 14 | - "2096:2096" 15 | networks: 16 | - s-ui 17 | entrypoint: "./entrypoint.sh" 18 | 19 | networks: 20 | s-ui: 21 | driver: bridge 22 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ./sui migrate 4 | ./sui -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module s-ui 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/gin-contrib/gzip v1.2.2 7 | github.com/gin-gonic/gin v1.10.0 8 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 9 | github.com/robfig/cron/v3 v3.0.1 10 | github.com/sagernet/sing v0.6.1 11 | github.com/sagernet/sing-box v1.11.3 12 | github.com/sagernet/sing-dns v0.4.0 13 | golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 14 | gorm.io/driver/sqlite v1.5.7 15 | gorm.io/gorm v1.25.12 16 | ) 17 | 18 | require ( 19 | github.com/ajg/form v1.5.1 // indirect 20 | github.com/andybalholm/brotli v1.0.6 // indirect 21 | github.com/bytedance/sonic v1.12.7 // indirect 22 | github.com/bytedance/sonic/loader v0.2.2 // indirect 23 | github.com/caddyserver/certmagic v0.20.0 // indirect 24 | github.com/cloudflare/circl v1.3.7 // indirect 25 | github.com/cloudwego/base64x v0.1.4 // indirect 26 | github.com/cretz/bine v0.2.0 // indirect 27 | github.com/ebitengine/purego v0.8.2 // indirect 28 | github.com/fsnotify/fsnotify v1.7.0 // indirect 29 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 30 | github.com/gin-contrib/sessions v1.0.2 31 | github.com/gin-contrib/sse v1.0.0 // indirect 32 | github.com/go-chi/chi/v5 v5.2.1 // indirect 33 | github.com/go-chi/render v1.0.3 // indirect 34 | github.com/go-ole/go-ole v1.3.0 // indirect 35 | github.com/go-playground/locales v0.14.1 // indirect 36 | github.com/go-playground/universal-translator v0.18.1 // indirect 37 | github.com/go-playground/validator/v10 v10.24.0 // indirect 38 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 39 | github.com/gobwas/httphead v0.1.0 // indirect 40 | github.com/gobwas/pool v0.2.1 // indirect 41 | github.com/goccy/go-json v0.10.4 // indirect 42 | github.com/gofrs/uuid/v5 v5.3.0 // indirect 43 | github.com/golang/protobuf v1.5.4 // indirect 44 | github.com/google/btree v1.1.3 // indirect 45 | github.com/google/go-cmp v0.6.0 // indirect 46 | github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a // indirect 47 | github.com/gorilla/context v1.1.2 // indirect 48 | github.com/gorilla/securecookie v1.1.2 // indirect 49 | github.com/gorilla/sessions v1.4.0 // indirect 50 | github.com/hashicorp/yamux v0.1.2 // indirect 51 | github.com/jinzhu/inflection v1.0.0 // indirect 52 | github.com/jinzhu/now v1.1.5 // indirect 53 | github.com/josharian/native v1.1.0 // indirect 54 | github.com/json-iterator/go v1.1.12 // indirect 55 | github.com/klauspost/compress v1.17.7 // indirect 56 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect 57 | github.com/leodido/go-urn v1.4.0 // indirect 58 | github.com/libdns/alidns v1.0.3 // indirect 59 | github.com/libdns/cloudflare v0.1.1 // indirect 60 | github.com/libdns/libdns v0.2.2 // indirect; indiresct 61 | github.com/logrusorgru/aurora v2.0.3+incompatible // indirect 62 | github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect 63 | github.com/mattn/go-isatty v0.0.20 // indirect 64 | github.com/mattn/go-sqlite3 v1.14.24 // indirect 65 | github.com/mdlayher/netlink v1.7.2 // indirect 66 | github.com/mdlayher/socket v0.4.1 // indirect 67 | github.com/metacubex/tfo-go v0.0.0-20241231083714-66613d49c422 // indirect 68 | github.com/mholt/acmez v1.2.0 // indirect 69 | github.com/miekg/dns v1.1.63 // indirect 70 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 71 | github.com/modern-go/reflect2 v1.0.2 // indirect 72 | github.com/onsi/ginkgo/v2 v2.10.0 // indirect 73 | github.com/oschwald/maxminddb-golang v1.12.0 // indirect 74 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 75 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 76 | github.com/quic-go/qpack v0.4.0 // indirect 77 | github.com/quic-go/qtls-go1-20 v0.4.1 // indirect 78 | github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect 79 | github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1 // indirect 80 | github.com/sagernet/cors v1.2.1 // indirect 81 | github.com/sagernet/fswatch v0.1.1 // indirect 82 | github.com/sagernet/gvisor v0.0.0-20241123041152-536d05261cff // indirect 83 | github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect 84 | github.com/sagernet/nftables v0.3.0-beta.4 // indirect 85 | github.com/sagernet/quic-go v0.49.0-beta.1 // indirect 86 | github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 // indirect 87 | github.com/sagernet/sing-mux v0.3.1 // indirect 88 | github.com/sagernet/sing-quic v0.4.0 // indirect 89 | github.com/sagernet/sing-shadowsocks v0.2.7 // indirect 90 | github.com/sagernet/sing-shadowsocks2 v0.2.0 // indirect 91 | github.com/sagernet/sing-shadowtls v0.2.0 // indirect 92 | github.com/sagernet/sing-tun v0.6.1 // indirect 93 | github.com/sagernet/sing-vmess v0.2.0 // indirect 94 | github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7 // indirect 95 | github.com/sagernet/utls v1.6.7 // indirect 96 | github.com/sagernet/wireguard-go v0.0.1-beta.5 // indirect 97 | github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect 98 | github.com/shirou/gopsutil/v4 v4.25.1 99 | github.com/tklauser/go-sysconf v0.3.14 // indirect 100 | github.com/tklauser/numcpus v0.9.0 // indirect 101 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 102 | github.com/ugorji/go/codec v1.2.12 // indirect 103 | github.com/vishvananda/netns v0.0.4 // indirect 104 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 105 | github.com/zeebo/blake3 v0.2.3 // indirect 106 | go.uber.org/multierr v1.11.0 // indirect 107 | go.uber.org/zap v1.27.0 // indirect 108 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 109 | golang.org/x/arch v0.13.0 // indirect 110 | golang.org/x/crypto v0.32.0 // indirect 111 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 112 | golang.org/x/mod v0.20.0 // indirect 113 | golang.org/x/net v0.34.0 // indirect 114 | golang.org/x/sync v0.10.0 // indirect 115 | golang.org/x/sys v0.30.0 // indirect 116 | golang.org/x/text v0.21.0 // indirect 117 | golang.org/x/time v0.7.0 // indirect 118 | golang.org/x/tools v0.24.0 // indirect 119 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 120 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect 121 | google.golang.org/grpc v1.67.1 // indirect 122 | google.golang.org/protobuf v1.36.2 // indirect 123 | gopkg.in/yaml.v3 v3.0.1 // indirect 124 | lukechampine.com/blake3 v1.3.0 // indirect 125 | ) 126 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | red='\033[0;31m' 4 | green='\033[0;32m' 5 | yellow='\033[0;33m' 6 | plain='\033[0m' 7 | 8 | cur_dir=$(pwd) 9 | 10 | # check root 11 | [[ $EUID -ne 0 ]] && echo -e "${red}Fatal error: ${plain} Please run this script with root privilege \n " && exit 1 12 | 13 | # Check OS and set release variable 14 | if [[ -f /etc/os-release ]]; then 15 | source /etc/os-release 16 | release=$ID 17 | elif [[ -f /usr/lib/os-release ]]; then 18 | source /usr/lib/os-release 19 | release=$ID 20 | else 21 | echo "Failed to check the system OS, please contact the author!" >&2 22 | exit 1 23 | fi 24 | echo "The OS release is: $release" 25 | 26 | arch() { 27 | case "$(uname -m)" in 28 | x86_64 | x64 | amd64) echo 'amd64' ;; 29 | i*86 | x86) echo '386' ;; 30 | armv8* | armv8 | arm64 | aarch64) echo 'arm64' ;; 31 | armv7* | armv7 | arm) echo 'armv7' ;; 32 | armv6* | armv6) echo 'armv6' ;; 33 | armv5* | armv5) echo 'armv5' ;; 34 | s390x) echo 's390x' ;; 35 | *) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;; 36 | esac 37 | } 38 | 39 | echo "arch: $(arch)" 40 | 41 | os_version="" 42 | os_version=$(grep -i version_id /etc/os-release | cut -d \" -f2 | cut -d . -f1) 43 | 44 | if [[ "${release}" == "arch" ]]; then 45 | echo "Your OS is Arch Linux" 46 | elif [[ "${release}" == "parch" ]]; then 47 | echo "Your OS is Parch linux" 48 | elif [[ "${release}" == "manjaro" ]]; then 49 | echo "Your OS is Manjaro" 50 | elif [[ "${release}" == "armbian" ]]; then 51 | echo "Your OS is Armbian" 52 | elif [[ "${release}" == "opensuse-tumbleweed" ]]; then 53 | echo "Your OS is OpenSUSE Tumbleweed" 54 | elif [[ "${release}" == "centos" ]]; then 55 | if [[ ${os_version} -lt 8 ]]; then 56 | echo -e "${red} Please use CentOS 8 or higher ${plain}\n" && exit 1 57 | fi 58 | elif [[ "${release}" == "ubuntu" ]]; then 59 | if [[ ${os_version} -lt 20 ]]; then 60 | echo -e "${red} Please use Ubuntu 20 or higher version!${plain}\n" && exit 1 61 | fi 62 | elif [[ "${release}" == "fedora" ]]; then 63 | if [[ ${os_version} -lt 36 ]]; then 64 | echo -e "${red} Please use Fedora 36 or higher version!${plain}\n" && exit 1 65 | fi 66 | elif [[ "${release}" == "debian" ]]; then 67 | if [[ ${os_version} -lt 11 ]]; then 68 | echo -e "${red} Please use Debian 11 or higher ${plain}\n" && exit 1 69 | fi 70 | elif [[ "${release}" == "almalinux" ]]; then 71 | if [[ ${os_version} -lt 9 ]]; then 72 | echo -e "${red} Please use AlmaLinux 9 or higher ${plain}\n" && exit 1 73 | fi 74 | elif [[ "${release}" == "rocky" ]]; then 75 | if [[ ${os_version} -lt 9 ]]; then 76 | echo -e "${red} Please use Rocky Linux 9 or higher ${plain}\n" && exit 1 77 | fi 78 | elif [[ "${release}" == "oracle" ]]; then 79 | if [[ ${os_version} -lt 8 ]]; then 80 | echo -e "${red} Please use Oracle Linux 8 or higher ${plain}\n" && exit 1 81 | fi 82 | else 83 | echo -e "${red}Your operating system is not supported by this script.${plain}\n" 84 | echo "Please ensure you are using one of the following supported operating systems:" 85 | echo "- Ubuntu 20.04+" 86 | echo "- Debian 11+" 87 | echo "- CentOS 8+" 88 | echo "- Fedora 36+" 89 | echo "- Arch Linux" 90 | echo "- Parch Linux" 91 | echo "- Manjaro" 92 | echo "- Armbian" 93 | echo "- AlmaLinux 9+" 94 | echo "- Rocky Linux 9+" 95 | echo "- Oracle Linux 8+" 96 | echo "- OpenSUSE Tumbleweed" 97 | exit 1 98 | fi 99 | 100 | 101 | install_base() { 102 | case "${release}" in 103 | centos | almalinux | rocky | oracle) 104 | yum -y update && yum install -y -q wget curl tar tzdata 105 | ;; 106 | fedora) 107 | dnf -y update && dnf install -y -q wget curl tar tzdata 108 | ;; 109 | arch | manjaro | parch) 110 | pacman -Syu && pacman -Syu --noconfirm wget curl tar tzdata 111 | ;; 112 | opensuse-tumbleweed) 113 | zypper refresh && zypper -q install -y wget curl tar timezone 114 | ;; 115 | *) 116 | apt-get update && apt-get install -y -q wget curl tar tzdata 117 | ;; 118 | esac 119 | } 120 | 121 | config_after_install() { 122 | echo -e "${yellow}Migration... ${plain}" 123 | /usr/local/s-ui/sui migrate 124 | 125 | echo -e "${yellow}Install/update finished! For security it's recommended to modify panel settings ${plain}" 126 | read -p "Do you want to continue with the modification [y/n]? ": config_confirm 127 | if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then 128 | echo -e "Enter the ${yellow}panel port${plain} (leave blank for existing/default value):" 129 | read config_port 130 | echo -e "Enter the ${yellow}panel path${plain} (leave blank for existing/default value):" 131 | read config_path 132 | 133 | # Sub configuration 134 | echo -e "Enter the ${yellow}subscription port${plain} (leave blank for existing/default value):" 135 | read config_subPort 136 | echo -e "Enter the ${yellow}subscription path${plain} (leave blank for existing/default value):" 137 | read config_subPath 138 | 139 | # Set configs 140 | echo -e "${yellow}Initializing, please wait...${plain}" 141 | params="" 142 | [ -z "$config_port" ] || params="$params -port $config_port" 143 | [ -z "$config_path" ] || params="$params -path $config_path" 144 | [ -z "$config_subPort" ] || params="$params -subPort $config_subPort" 145 | [ -z "$config_subPath" ] || params="$params -subPath $config_subPath" 146 | /usr/local/s-ui/sui setting ${params} 147 | 148 | read -p "Do you want to change admin credentials [y/n]? ": admin_confirm 149 | if [[ "${admin_confirm}" == "y" || "${admin_confirm}" == "Y" ]]; then 150 | # First admin credentials 151 | read -p "Please set up your username:" config_account 152 | read -p "Please set up your password:" config_password 153 | 154 | # Set credentials 155 | echo -e "${yellow}Initializing, please wait...${plain}" 156 | /usr/local/s-ui/sui admin -username ${config_account} -password ${config_password} 157 | else 158 | echo -e "${yellow}Your current admin credentials: ${plain}" 159 | /usr/local/s-ui/sui admin -show 160 | fi 161 | else 162 | echo -e "${red}cancel...${plain}" 163 | if [[ ! -f "/usr/local/s-ui/db/s-ui.db" ]]; then 164 | local usernameTemp=$(head -c 6 /dev/urandom | base64) 165 | local passwordTemp=$(head -c 6 /dev/urandom | base64) 166 | echo -e "this is a fresh installation,will generate random login info for security concerns:" 167 | echo -e "###############################################" 168 | echo -e "${green}username:${usernameTemp}${plain}" 169 | echo -e "${green}password:${passwordTemp}${plain}" 170 | echo -e "###############################################" 171 | echo -e "${red}if you forgot your login info,you can type ${green}s-ui${red} for configuration menu${plain}" 172 | /usr/local/s-ui/sui admin -username ${usernameTemp} -password ${passwordTemp} 173 | else 174 | echo -e "${red} this is your upgrade,will keep old settings,if you forgot your login info,you can type ${green}s-ui${red} for configuration menu${plain}" 175 | fi 176 | fi 177 | } 178 | 179 | prepare_services() { 180 | if [[ -f "/etc/systemd/system/sing-box.service" ]]; then 181 | echo -e "${yellow}Stopping sing-box service... ${plain}" 182 | systemctl stop sing-box 183 | rm -f /usr/local/s-ui/bin/sing-box /usr/local/s-ui/bin/runSingbox.sh /usr/local/s-ui/bin/signal 184 | fi 185 | if [[ -e "/usr/local/s-ui/bin" ]]; then 186 | echo -e "###############################################################" 187 | echo -e "${green}/usr/local/s-ui/bin${red} directory exists yet!" 188 | echo -e "Please check the content and delete it manually after migration ${plain}" 189 | echo -e "###############################################################" 190 | fi 191 | systemctl daemon-reload 192 | } 193 | 194 | install_s-ui() { 195 | cd /tmp/ 196 | 197 | if [ $# == 0 ]; then 198 | last_version=$(curl -Ls "https://api.github.com/repos/alireza0/s-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') 199 | if [[ ! -n "$last_version" ]]; then 200 | echo -e "${red}Failed to fetch s-ui version, it maybe due to Github API restrictions, please try it later${plain}" 201 | exit 1 202 | fi 203 | echo -e "Got s-ui latest version: ${last_version}, beginning the installation..." 204 | wget -N --no-check-certificate -O /tmp/s-ui-linux-$(arch).tar.gz https://github.com/alireza0/s-ui/releases/download/${last_version}/s-ui-linux-$(arch).tar.gz 205 | if [[ $? -ne 0 ]]; then 206 | echo -e "${red}Downloading s-ui failed, please be sure that your server can access Github ${plain}" 207 | exit 1 208 | fi 209 | else 210 | last_version=$1 211 | url="https://github.com/alireza0/s-ui/releases/download/${last_version}/s-ui-linux-$(arch).tar.gz" 212 | echo -e "Beginning the install s-ui v$1" 213 | wget -N --no-check-certificate -O /tmp/s-ui-linux-$(arch).tar.gz ${url} 214 | if [[ $? -ne 0 ]]; then 215 | echo -e "${red}download s-ui v$1 failed,please check the version exists${plain}" 216 | exit 1 217 | fi 218 | fi 219 | 220 | if [[ -e /usr/local/s-ui/ ]]; then 221 | systemctl stop s-ui 222 | fi 223 | 224 | tar zxvf s-ui-linux-$(arch).tar.gz 225 | rm s-ui-linux-$(arch).tar.gz -f 226 | 227 | chmod +x s-ui/sui s-ui/s-ui.sh 228 | cp s-ui/s-ui.sh /usr/bin/s-ui 229 | cp -rf s-ui /usr/local/ 230 | cp -f s-ui/*.service /etc/systemd/system/ 231 | rm -rf s-ui 232 | 233 | config_after_install 234 | prepare_services 235 | 236 | systemctl enable s-ui --now 237 | 238 | echo -e "${green}s-ui v${last_version}${plain} installation finished, it is up and running now..." 239 | echo -e "You may access the Panel with following URL(s):${green}" 240 | /usr/local/s-ui/sui uri 241 | echo -e "${plain}" 242 | echo -e "" 243 | s-ui help 244 | } 245 | 246 | echo -e "${green}Executing...${plain}" 247 | install_base 248 | install_s-ui $1 249 | -------------------------------------------------------------------------------- /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 ( 12 | logger *logging.Logger 13 | logBuffer []struct { 14 | time string 15 | level logging.Level 16 | log string 17 | } 18 | ) 19 | 20 | func InitLogger(level logging.Level) { 21 | newLogger := logging.MustGetLogger("s-ui") 22 | var err error 23 | var backend logging.Backend 24 | var format logging.Formatter 25 | 26 | backend, err = logging.NewSyslogBackend("") 27 | if err != nil { 28 | fmt.Println("Unable to use syslog: " + err.Error()) 29 | backend = logging.NewLogBackend(os.Stderr, "", 0) 30 | } 31 | if err != nil { 32 | format = logging.MustStringFormatter(`%{time:2006/01/02 15:04:05} %{level} - %{message}`) 33 | } else { 34 | format = logging.MustStringFormatter(`%{level} - %{message}`) 35 | } 36 | 37 | backendFormatter := logging.NewBackendFormatter(backend, format) 38 | backendLeveled := logging.AddModuleLevel(backendFormatter) 39 | backendLeveled.SetLevel(level, "s-ui") 40 | newLogger.SetBackend(backendLeveled) 41 | 42 | logger = newLogger 43 | } 44 | 45 | func GetLogger() *logging.Logger { 46 | return logger 47 | } 48 | 49 | func Debug(args ...interface{}) { 50 | logger.Debug(args...) 51 | addToBuffer("DEBUG", fmt.Sprint(args...)) 52 | } 53 | 54 | func Debugf(format string, args ...interface{}) { 55 | logger.Debugf(format, args...) 56 | addToBuffer("DEBUG", fmt.Sprintf(format, args...)) 57 | } 58 | 59 | func Info(args ...interface{}) { 60 | logger.Info(args...) 61 | addToBuffer("INFO", fmt.Sprint(args...)) 62 | } 63 | 64 | func Infof(format string, args ...interface{}) { 65 | logger.Infof(format, args...) 66 | addToBuffer("INFO", fmt.Sprintf(format, args...)) 67 | } 68 | 69 | func Warning(args ...interface{}) { 70 | logger.Warning(args...) 71 | addToBuffer("WARNING", fmt.Sprint(args...)) 72 | } 73 | 74 | func Warningf(format string, args ...interface{}) { 75 | logger.Warningf(format, args...) 76 | addToBuffer("WARNING", fmt.Sprintf(format, args...)) 77 | } 78 | 79 | func Error(args ...interface{}) { 80 | logger.Error(args...) 81 | addToBuffer("ERROR", fmt.Sprint(args...)) 82 | } 83 | 84 | func Errorf(format string, args ...interface{}) { 85 | logger.Errorf(format, args...) 86 | addToBuffer("ERROR", fmt.Sprintf(format, args...)) 87 | } 88 | 89 | func addToBuffer(level string, newLog string) { 90 | t := time.Now() 91 | if len(logBuffer) >= 10240 { 92 | logBuffer = logBuffer[1:] 93 | } 94 | 95 | logLevel, _ := logging.LogLevel(level) 96 | logBuffer = append(logBuffer, struct { 97 | time string 98 | level logging.Level 99 | log string 100 | }{ 101 | time: t.Format("2006/01/02 15:04:05"), 102 | level: logLevel, 103 | log: newLog, 104 | }) 105 | } 106 | 107 | func GetLogs(c int, level string) []string { 108 | var output []string 109 | logLevel, _ := logging.LogLevel(level) 110 | 111 | for i := len(logBuffer) - 1; i >= 0 && len(output) <= c; i-- { 112 | if logBuffer[i].level <= logLevel { 113 | output = append(output, fmt.Sprintf("%s %s - %s", logBuffer[i].time, logBuffer[i].level, logBuffer[i].log)) 114 | } 115 | } 116 | return output 117 | } 118 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /middleware/domainValidator.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func DomainValidator(domain string) gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | host := c.Request.Host 14 | if colonIndex := strings.LastIndex(host, ":"); colonIndex != -1 { 15 | host, _, _ = net.SplitHostPort(c.Request.Host) 16 | } 17 | 18 | if host != domain { 19 | c.AbortWithStatus(http.StatusForbidden) 20 | return 21 | } 22 | 23 | c.Next() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "s-ui", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /service/config.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "s-ui/core" 6 | "s-ui/database" 7 | "s-ui/database/model" 8 | "s-ui/logger" 9 | "s-ui/util/common" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | var ( 15 | LastUpdate int64 16 | corePtr *core.Core 17 | ) 18 | 19 | type ConfigService struct { 20 | ClientService 21 | TlsService 22 | SettingService 23 | InboundService 24 | OutboundService 25 | EndpointService 26 | } 27 | 28 | type SingBoxConfig struct { 29 | Log json.RawMessage `json:"log"` 30 | Dns json.RawMessage `json:"dns"` 31 | Ntp json.RawMessage `json:"ntp"` 32 | Inbounds []json.RawMessage `json:"inbounds"` 33 | Outbounds []json.RawMessage `json:"outbounds"` 34 | Endpoints []json.RawMessage `json:"endpoints"` 35 | Route json.RawMessage `json:"route"` 36 | Experimental json.RawMessage `json:"experimental"` 37 | } 38 | 39 | func NewConfigService(core *core.Core) *ConfigService { 40 | corePtr = core 41 | return &ConfigService{} 42 | } 43 | 44 | func (s *ConfigService) GetConfig(data string) (*SingBoxConfig, error) { 45 | var err error 46 | if len(data) == 0 { 47 | data, err = s.SettingService.GetConfig() 48 | if err != nil { 49 | return nil, err 50 | } 51 | } 52 | singboxConfig := SingBoxConfig{} 53 | err = json.Unmarshal([]byte(data), &singboxConfig) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | singboxConfig.Inbounds, err = s.InboundService.GetAllConfig(database.GetDB()) 59 | if err != nil { 60 | return nil, err 61 | } 62 | singboxConfig.Outbounds, err = s.OutboundService.GetAllConfig(database.GetDB()) 63 | if err != nil { 64 | return nil, err 65 | } 66 | singboxConfig.Endpoints, err = s.EndpointService.GetAllConfig(database.GetDB()) 67 | if err != nil { 68 | return nil, err 69 | } 70 | return &singboxConfig, nil 71 | } 72 | 73 | func (s *ConfigService) StartCore(defaultConfig string) error { 74 | if corePtr.IsRunning() { 75 | return nil 76 | } 77 | singboxConfig, err := s.GetConfig(defaultConfig) 78 | if err != nil { 79 | return err 80 | } 81 | rawConfig, err := json.MarshalIndent(singboxConfig, "", " ") 82 | if err != nil { 83 | return err 84 | } 85 | err = corePtr.Start(rawConfig) 86 | if err != nil { 87 | logger.Error("start sing-box err:", err.Error()) 88 | return err 89 | } 90 | logger.Info("sing-box started") 91 | return nil 92 | } 93 | 94 | func (s *ConfigService) RestartCore() error { 95 | err := s.StopCore() 96 | if err != nil { 97 | return err 98 | } 99 | return s.StartCore("") 100 | } 101 | 102 | func (s *ConfigService) restartCoreWithConfig(config json.RawMessage) error { 103 | err := s.StopCore() 104 | if err != nil { 105 | return err 106 | } 107 | return s.StartCore(string(config)) 108 | } 109 | 110 | func (s *ConfigService) StopCore() error { 111 | err := corePtr.Stop() 112 | if err != nil { 113 | return err 114 | } 115 | logger.Info("sing-box stopped") 116 | return nil 117 | } 118 | 119 | func (s *ConfigService) Save(obj string, act string, data json.RawMessage, initUsers string, loginUser string, hostname string) ([]string, error) { 120 | var err error 121 | var inboundIds []uint 122 | var inboundId uint 123 | var objs []string = []string{obj} 124 | 125 | db := database.GetDB() 126 | tx := db.Begin() 127 | defer func() { 128 | if err == nil { 129 | tx.Commit() 130 | if len(inboundIds) > 0 && corePtr.IsRunning() { 131 | err1 := s.InboundService.RestartInbounds(db, inboundIds) 132 | if err1 != nil { 133 | logger.Error("unable to restart inbounds: ", err1) 134 | } 135 | } 136 | // Try to start core if it is not running 137 | if !corePtr.IsRunning() { 138 | s.StartCore("") 139 | } 140 | } else { 141 | tx.Rollback() 142 | } 143 | }() 144 | 145 | switch obj { 146 | case "clients": 147 | inboundIds, err = s.ClientService.Save(tx, act, data, hostname) 148 | objs = append(objs, "inbounds") 149 | case "tls": 150 | inboundIds, err = s.TlsService.Save(tx, act, data) 151 | case "inbounds": 152 | inboundId, err = s.InboundService.Save(tx, act, data, initUsers, hostname) 153 | case "outbounds": 154 | err = s.OutboundService.Save(tx, act, data) 155 | case "endpoints": 156 | err = s.EndpointService.Save(tx, act, data) 157 | case "config": 158 | err = s.SettingService.SaveConfig(tx, data) 159 | if err != nil { 160 | return nil, err 161 | } 162 | err = s.restartCoreWithConfig(data) 163 | case "settings": 164 | err = s.SettingService.Save(tx, data) 165 | default: 166 | return nil, common.NewError("unknown object: ", obj) 167 | } 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | dt := time.Now().Unix() 173 | err = tx.Create(&model.Changes{ 174 | DateTime: dt, 175 | Actor: loginUser, 176 | Key: obj, 177 | Action: act, 178 | Obj: data, 179 | }).Error 180 | if err != nil { 181 | return nil, err 182 | } 183 | // Commit changes so far 184 | tx.Commit() 185 | LastUpdate = time.Now().Unix() 186 | tx = db.Begin() 187 | 188 | // Update side changes 189 | 190 | // Update client links 191 | if obj == "tls" && len(inboundIds) > 0 { 192 | err = s.ClientService.UpdateLinksByInboundChange(tx, inboundIds, hostname) 193 | if err != nil { 194 | return nil, err 195 | } 196 | objs = append(objs, "clients") 197 | } 198 | if obj == "inbounds" { 199 | switch act { 200 | case "new": 201 | err = s.ClientService.UpdateClientsOnInboundAdd(tx, initUsers, inboundId, hostname) 202 | case "edit": 203 | err = s.ClientService.UpdateLinksByInboundChange(tx, []uint{inboundId}, hostname) 204 | case "del": 205 | var tag string 206 | err = json.Unmarshal(data, &tag) 207 | if err != nil { 208 | return nil, err 209 | } 210 | err = s.ClientService.UpdateClientsOnInboundDelete(tx, inboundId, tag) 211 | } 212 | if err != nil { 213 | return nil, err 214 | } 215 | objs = append(objs, "clients") 216 | } 217 | 218 | // Update out_json of inbounds when tls is changed 219 | if obj == "tls" && len(inboundIds) > 0 { 220 | err = s.InboundService.UpdateOutJsons(tx, inboundIds, hostname) 221 | if err != nil { 222 | return nil, common.NewError("unable to update out_json of inbounds: ", err.Error()) 223 | } 224 | objs = append(objs, "inbounds") 225 | } 226 | 227 | return objs, nil 228 | } 229 | 230 | func (s *ConfigService) CheckChanges(lu string) (bool, error) { 231 | if lu == "" { 232 | return true, nil 233 | } 234 | if LastUpdate == 0 { 235 | db := database.GetDB() 236 | var count int64 237 | err := db.Model(model.Changes{}).Where("date_time > " + lu).Count(&count).Error 238 | if err == nil { 239 | LastUpdate = time.Now().Unix() 240 | } 241 | return count > 0, err 242 | } else { 243 | intLu, err := strconv.ParseInt(lu, 10, 64) 244 | return LastUpdate > intLu, err 245 | } 246 | } 247 | 248 | func (s *ConfigService) GetChanges(actor string, chngKey string, count string) []model.Changes { 249 | c, _ := strconv.Atoi(count) 250 | whereString := "`id`>0" 251 | if len(actor) > 0 { 252 | whereString += " and `actor`='" + actor + "'" 253 | } 254 | if len(chngKey) > 0 { 255 | whereString += " and `key`='" + chngKey + "'" 256 | } 257 | db := database.GetDB() 258 | var chngs []model.Changes 259 | err := db.Model(model.Changes{}).Where(whereString).Order("`id` desc").Limit(c).Scan(&chngs).Error 260 | if err != nil { 261 | logger.Warning(err) 262 | } 263 | return chngs 264 | } 265 | -------------------------------------------------------------------------------- /service/endpoints.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "s-ui/database" 7 | "s-ui/database/model" 8 | "s-ui/util/common" 9 | 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type EndpointService struct { 14 | WarpService 15 | } 16 | 17 | func (o *EndpointService) GetAll() (*[]map[string]interface{}, error) { 18 | db := database.GetDB() 19 | endpoints := []*model.Endpoint{} 20 | err := db.Model(model.Endpoint{}).Scan(&endpoints).Error 21 | if err != nil { 22 | return nil, err 23 | } 24 | var data []map[string]interface{} 25 | for _, endpoint := range endpoints { 26 | epData := map[string]interface{}{ 27 | "id": endpoint.Id, 28 | "type": endpoint.Type, 29 | "tag": endpoint.Tag, 30 | "ext": endpoint.Ext, 31 | } 32 | if endpoint.Options != nil { 33 | var restFields map[string]json.RawMessage 34 | if err := json.Unmarshal(endpoint.Options, &restFields); err != nil { 35 | return nil, err 36 | } 37 | for k, v := range restFields { 38 | epData[k] = v 39 | } 40 | } 41 | data = append(data, epData) 42 | } 43 | return &data, nil 44 | } 45 | 46 | func (o *EndpointService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) { 47 | var endpointsJson []json.RawMessage 48 | var endpoints []*model.Endpoint 49 | err := db.Model(model.Endpoint{}).Scan(&endpoints).Error 50 | if err != nil { 51 | return nil, err 52 | } 53 | for _, endpoint := range endpoints { 54 | endpointJson, err := endpoint.MarshalJSON() 55 | if err != nil { 56 | return nil, err 57 | } 58 | endpointsJson = append(endpointsJson, endpointJson) 59 | } 60 | return endpointsJson, nil 61 | } 62 | 63 | func (s *EndpointService) Save(tx *gorm.DB, act string, data json.RawMessage) error { 64 | var err error 65 | 66 | switch act { 67 | case "new", "edit": 68 | var endpoint model.Endpoint 69 | err = endpoint.UnmarshalJSON(data) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | if endpoint.Type == "warp" { 75 | if act == "new" { 76 | err = s.WarpService.RegisterWarp(&endpoint) 77 | if err != nil { 78 | return err 79 | } 80 | } else { 81 | var old_license string 82 | err = tx.Model(model.Endpoint{}).Select("json_extract(ext, '$.license_key')").Where("id = ?", endpoint.Id).Find(&old_license).Error 83 | if err != nil { 84 | return err 85 | } 86 | err = s.WarpService.SetWarpLicense(old_license, &endpoint) 87 | if err != nil { 88 | return err 89 | } 90 | } 91 | } 92 | 93 | if corePtr.IsRunning() { 94 | configData, err := endpoint.MarshalJSON() 95 | if err != nil { 96 | return err 97 | } 98 | if act == "edit" { 99 | var oldTag string 100 | err = tx.Model(model.Endpoint{}).Select("tag").Where("id = ?", endpoint.Id).Find(&oldTag).Error 101 | if err != nil { 102 | return err 103 | } 104 | err = corePtr.RemoveEndpoint(oldTag) 105 | if err != nil && err != os.ErrInvalid { 106 | return err 107 | } 108 | } 109 | err = corePtr.AddEndpoint(configData) 110 | if err != nil { 111 | return err 112 | } 113 | } 114 | 115 | err = tx.Save(&endpoint).Error 116 | if err != nil { 117 | return err 118 | } 119 | case "del": 120 | var tag string 121 | err = json.Unmarshal(data, &tag) 122 | if err != nil { 123 | return err 124 | } 125 | if corePtr.IsRunning() { 126 | err = corePtr.RemoveEndpoint(tag) 127 | if err != nil && err != os.ErrInvalid { 128 | return err 129 | } 130 | } 131 | err = tx.Where("tag = ?", tag).Delete(model.Endpoint{}).Error 132 | if err != nil { 133 | return err 134 | } 135 | default: 136 | return common.NewErrorf("unknown action: %s", act) 137 | } 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /service/inbounds.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "s-ui/database" 8 | "s-ui/database/model" 9 | "s-ui/util" 10 | "s-ui/util/common" 11 | "strings" 12 | 13 | "gorm.io/gorm" 14 | ) 15 | 16 | type InboundService struct{} 17 | 18 | func (s *InboundService) Get(ids string) (*[]map[string]interface{}, error) { 19 | if ids == "" { 20 | return s.GetAll() 21 | } 22 | return s.getById(ids) 23 | } 24 | 25 | func (s *InboundService) getById(ids string) (*[]map[string]interface{}, error) { 26 | var inbound []model.Inbound 27 | var result []map[string]interface{} 28 | db := database.GetDB() 29 | err := db.Model(model.Inbound{}).Where("id in ?", strings.Split(ids, ",")).Scan(&inbound).Error 30 | if err != nil { 31 | return nil, err 32 | } 33 | for _, inb := range inbound { 34 | inbData, err := inb.MarshalFull() 35 | if err != nil { 36 | return nil, err 37 | } 38 | result = append(result, *inbData) 39 | } 40 | return &result, nil 41 | } 42 | 43 | func (s *InboundService) GetAll() (*[]map[string]interface{}, error) { 44 | db := database.GetDB() 45 | inbounds := []model.Inbound{} 46 | err := db.Model(model.Inbound{}).Scan(&inbounds).Error 47 | if err != nil { 48 | return nil, err 49 | } 50 | var data []map[string]interface{} 51 | for _, inbound := range inbounds { 52 | var shadowtls_version uint 53 | inbData := map[string]interface{}{ 54 | "id": inbound.Id, 55 | "type": inbound.Type, 56 | "tag": inbound.Tag, 57 | "tls_id": inbound.TlsId, 58 | } 59 | if inbound.Options != nil { 60 | var restFields map[string]json.RawMessage 61 | if err := json.Unmarshal(inbound.Options, &restFields); err != nil { 62 | return nil, err 63 | } 64 | inbData["listen"] = restFields["listen"] 65 | inbData["listen_port"] = restFields["listen_port"] 66 | if inbound.Type == "shadowtls" { 67 | json.Unmarshal(restFields["version"], &shadowtls_version) 68 | } 69 | } 70 | if s.hasUser(inbound.Type) { 71 | if inbound.Type == "shadowtls" && shadowtls_version < 3 { 72 | break 73 | } 74 | users := []string{} 75 | err = db.Raw("SELECT clients.name FROM clients, json_each(clients.inbounds) as je WHERE je.value = ?", inbound.Id).Scan(&users).Error 76 | if err != nil { 77 | return nil, err 78 | } 79 | inbData["users"] = users 80 | } 81 | 82 | data = append(data, inbData) 83 | } 84 | return &data, nil 85 | } 86 | 87 | func (s *InboundService) FromIds(ids []uint) ([]*model.Inbound, error) { 88 | db := database.GetDB() 89 | inbounds := []*model.Inbound{} 90 | err := db.Model(model.Inbound{}).Where("id in ?", ids).Scan(&inbounds).Error 91 | if err != nil { 92 | return nil, err 93 | } 94 | return inbounds, nil 95 | } 96 | 97 | func (s *InboundService) Save(tx *gorm.DB, act string, data json.RawMessage, initUserIds string, hostname string) (uint, error) { 98 | var err error 99 | var id uint 100 | 101 | switch act { 102 | case "new", "edit": 103 | var inbound model.Inbound 104 | err = inbound.UnmarshalJSON(data) 105 | if err != nil { 106 | return 0, err 107 | } 108 | if inbound.TlsId > 0 { 109 | err = tx.Model(model.Tls{}).Where("id = ?", inbound.TlsId).Find(&inbound.Tls).Error 110 | if err != nil { 111 | return 0, err 112 | } 113 | } 114 | 115 | err = util.FillOutJson(&inbound, hostname) 116 | if err != nil { 117 | return 0, err 118 | } 119 | 120 | err = tx.Save(&inbound).Error 121 | if err != nil { 122 | return 0, err 123 | } 124 | id = inbound.Id 125 | 126 | if corePtr.IsRunning() { 127 | if act == "edit" { 128 | var oldTag string 129 | err = tx.Model(model.Inbound{}).Select("tag").Where("id = ?", inbound.Id).Find(&oldTag).Error 130 | if err != nil { 131 | return 0, err 132 | } 133 | err = corePtr.RemoveInbound(oldTag) 134 | if err != nil && err != os.ErrInvalid { 135 | return 0, err 136 | } 137 | } 138 | 139 | inboundConfig, err := inbound.MarshalJSON() 140 | if err != nil { 141 | return 0, err 142 | } 143 | 144 | if act == "edit" { 145 | inboundConfig, err = s.addUsers(tx, inboundConfig, inbound.Id, inbound.Type) 146 | } else { 147 | inboundConfig, err = s.initUsers(tx, inboundConfig, initUserIds, inbound.Type) 148 | } 149 | if err != nil { 150 | return 0, err 151 | } 152 | 153 | err = corePtr.AddInbound(inboundConfig) 154 | if err != nil { 155 | return 0, err 156 | } 157 | } 158 | case "del": 159 | var tag string 160 | err = json.Unmarshal(data, &tag) 161 | if err != nil { 162 | return 0, err 163 | } 164 | if corePtr.IsRunning() { 165 | err = corePtr.RemoveInbound(tag) 166 | if err != nil && err != os.ErrInvalid { 167 | return 0, err 168 | } 169 | } 170 | err = tx.Model(model.Inbound{}).Select("id").Where("tag = ?", tag).Scan(&id).Error 171 | if err != nil { 172 | return 0, err 173 | } 174 | err = tx.Where("tag = ?", tag).Delete(model.Inbound{}).Error 175 | if err != nil { 176 | return 0, err 177 | } 178 | default: 179 | return 0, common.NewErrorf("unknown action: %s", act) 180 | } 181 | return id, nil 182 | } 183 | 184 | func (s *InboundService) UpdateOutJsons(tx *gorm.DB, inboundIds []uint, hostname string) error { 185 | var inbounds []model.Inbound 186 | err := tx.Model(model.Inbound{}).Preload("Tls").Where("id in ?", inboundIds).Find(&inbounds).Error 187 | if err != nil { 188 | return err 189 | } 190 | for _, inbound := range inbounds { 191 | err = util.FillOutJson(&inbound, hostname) 192 | if err != nil { 193 | return err 194 | } 195 | err = tx.Model(model.Inbound{}).Where("tag = ?", inbound.Tag).Update("out_json", inbound.OutJson).Error 196 | if err != nil { 197 | return err 198 | } 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func (s *InboundService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) { 205 | var inboundsJson []json.RawMessage 206 | var inbounds []*model.Inbound 207 | err := db.Model(model.Inbound{}).Preload("Tls").Find(&inbounds).Error 208 | if err != nil { 209 | return nil, err 210 | } 211 | for _, inbound := range inbounds { 212 | inboundJson, err := inbound.MarshalJSON() 213 | if err != nil { 214 | return nil, err 215 | } 216 | inboundJson, err = s.addUsers(db, inboundJson, inbound.Id, inbound.Type) 217 | if err != nil { 218 | return nil, err 219 | } 220 | inboundsJson = append(inboundsJson, inboundJson) 221 | } 222 | return inboundsJson, nil 223 | } 224 | 225 | func (s *InboundService) hasUser(inboundType string) bool { 226 | switch inboundType { 227 | case "mixed", "socks", "http", "shadowsocks", "vmess", "trojan", "naive", "hysteria", "shadowtls", "tuic", "hysteria2", "vless": 228 | return true 229 | } 230 | return false 231 | } 232 | 233 | func (s *InboundService) fetchUsers(db *gorm.DB, inboundType string, condition string, inbound map[string]interface{}) ([]json.RawMessage, error) { 234 | if inboundType == "shadowtls" { 235 | version, _ := inbound["version"].(float64) 236 | if int(version) < 3 { 237 | return nil, nil 238 | } 239 | } 240 | if inboundType == "shadowsocks" { 241 | method, _ := inbound["method"].(string) 242 | if method == "2022-blake3-aes-128-gcm" { 243 | inboundType = "shadowsocks16" 244 | } 245 | } 246 | 247 | var users []string 248 | err := db.Raw(`SELECT json_extract(clients.config, ?) FROM clients WHERE enable = true AND ?`, 249 | "$."+inboundType, condition).Scan(&users).Error 250 | if err != nil { 251 | return nil, err 252 | } 253 | var usersJson []json.RawMessage 254 | for _, user := range users { 255 | if inboundType == "vless" && inbound["tls"] == nil { 256 | user = strings.Replace(user, "xtls-rprx-vision", "", -1) 257 | } 258 | usersJson = append(usersJson, json.RawMessage(user)) 259 | } 260 | return usersJson, nil 261 | } 262 | 263 | func (s *InboundService) addUsers(db *gorm.DB, inboundJson []byte, inboundId uint, inboundType string) ([]byte, error) { 264 | if !s.hasUser(inboundType) { 265 | return inboundJson, nil 266 | } 267 | 268 | var inbound map[string]interface{} 269 | err := json.Unmarshal(inboundJson, &inbound) 270 | if err != nil { 271 | return nil, err 272 | } 273 | 274 | condition := fmt.Sprintf("%d IN (SELECT json_each.value FROM json_each(clients.inbounds))", inboundId) 275 | inbound["users"], err = s.fetchUsers(db, inboundType, condition, inbound) 276 | if err != nil { 277 | return nil, err 278 | } 279 | 280 | return json.Marshal(inbound) 281 | } 282 | 283 | func (s *InboundService) initUsers(db *gorm.DB, inboundJson []byte, clientIds string, inboundType string) ([]byte, error) { 284 | ClientIds := strings.Split(clientIds, ",") 285 | if len(ClientIds) == 0 { 286 | return inboundJson, nil 287 | } 288 | 289 | if !s.hasUser(inboundType) { 290 | return inboundJson, nil 291 | } 292 | 293 | var inbound map[string]interface{} 294 | err := json.Unmarshal(inboundJson, &inbound) 295 | if err != nil { 296 | return nil, err 297 | } 298 | 299 | condition := fmt.Sprintf("id IN (%s)", strings.Join(ClientIds, ",")) 300 | inbound["users"], err = s.fetchUsers(db, inboundType, condition, inbound) 301 | if err != nil { 302 | return nil, err 303 | } 304 | 305 | return json.Marshal(inbound) 306 | } 307 | 308 | func (s *InboundService) RestartInbounds(tx *gorm.DB, ids []uint) error { 309 | var inbounds []*model.Inbound 310 | err := tx.Model(model.Inbound{}).Preload("Tls").Where("id in ?", ids).Find(&inbounds).Error 311 | if err != nil { 312 | return err 313 | } 314 | for _, inbound := range inbounds { 315 | err = corePtr.RemoveInbound(inbound.Tag) 316 | if err != nil && err != os.ErrInvalid { 317 | return err 318 | } 319 | inboundConfig, err := inbound.MarshalJSON() 320 | if err != nil { 321 | return err 322 | } 323 | inboundConfig, err = s.addUsers(tx, inboundConfig, inbound.Id, inbound.Type) 324 | if err != nil { 325 | return err 326 | } 327 | err = corePtr.AddInbound(inboundConfig) 328 | if err != nil { 329 | return err 330 | } 331 | } 332 | return nil 333 | } 334 | -------------------------------------------------------------------------------- /service/outbounds.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "s-ui/database" 7 | "s-ui/database/model" 8 | "s-ui/util/common" 9 | 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type OutboundService struct{} 14 | 15 | func (o *OutboundService) GetAll() (*[]map[string]interface{}, error) { 16 | db := database.GetDB() 17 | outbounds := []*model.Outbound{} 18 | err := db.Model(model.Outbound{}).Scan(&outbounds).Error 19 | if err != nil { 20 | return nil, err 21 | } 22 | var data []map[string]interface{} 23 | for _, outbound := range outbounds { 24 | outData := map[string]interface{}{ 25 | "id": outbound.Id, 26 | "type": outbound.Type, 27 | "tag": outbound.Tag, 28 | } 29 | if outbound.Options != nil { 30 | var restFields map[string]json.RawMessage 31 | if err := json.Unmarshal(outbound.Options, &restFields); err != nil { 32 | return nil, err 33 | } 34 | for k, v := range restFields { 35 | outData[k] = v 36 | } 37 | } 38 | data = append(data, outData) 39 | } 40 | return &data, nil 41 | } 42 | 43 | func (o *OutboundService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) { 44 | var outboundsJson []json.RawMessage 45 | var outbounds []*model.Outbound 46 | err := db.Model(model.Outbound{}).Scan(&outbounds).Error 47 | if err != nil { 48 | return nil, err 49 | } 50 | for _, outbound := range outbounds { 51 | outboundJson, err := outbound.MarshalJSON() 52 | if err != nil { 53 | return nil, err 54 | } 55 | outboundsJson = append(outboundsJson, outboundJson) 56 | } 57 | return outboundsJson, nil 58 | } 59 | 60 | func (s *OutboundService) Save(tx *gorm.DB, act string, data json.RawMessage) error { 61 | var err error 62 | 63 | switch act { 64 | case "new", "edit": 65 | var outbound model.Outbound 66 | err = outbound.UnmarshalJSON(data) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if corePtr.IsRunning() { 72 | configData, err := outbound.MarshalJSON() 73 | if err != nil { 74 | return err 75 | } 76 | if act == "edit" { 77 | var oldTag string 78 | err = tx.Model(model.Outbound{}).Select("tag").Where("id = ?", outbound.Id).Find(&oldTag).Error 79 | if err != nil { 80 | return err 81 | } 82 | err = corePtr.RemoveOutbound(oldTag) 83 | if err != nil && err != os.ErrInvalid { 84 | return err 85 | } 86 | } 87 | err = corePtr.AddOutbound(configData) 88 | if err != nil { 89 | return err 90 | } 91 | } 92 | 93 | err = tx.Save(&outbound).Error 94 | if err != nil { 95 | return err 96 | } 97 | case "del": 98 | var tag string 99 | err = json.Unmarshal(data, &tag) 100 | if err != nil { 101 | return err 102 | } 103 | if corePtr.IsRunning() { 104 | err = corePtr.RemoveOutbound(tag) 105 | if err != nil && err != os.ErrInvalid { 106 | return err 107 | } 108 | } 109 | err = tx.Where("tag = ?", tag).Delete(model.Outbound{}).Error 110 | if err != nil { 111 | return err 112 | } 113 | default: 114 | return common.NewErrorf("unknown action: %s", act) 115 | } 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /service/server.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/base64" 5 | "os" 6 | "runtime" 7 | "s-ui/config" 8 | "s-ui/logger" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/sagernet/sing-box/common/tls" 14 | "github.com/shirou/gopsutil/v4/cpu" 15 | "github.com/shirou/gopsutil/v4/host" 16 | "github.com/shirou/gopsutil/v4/mem" 17 | "github.com/shirou/gopsutil/v4/net" 18 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes" 19 | ) 20 | 21 | type ServerService struct{} 22 | 23 | func (s *ServerService) GetStatus(request string) *map[string]interface{} { 24 | status := make(map[string]interface{}, 0) 25 | requests := strings.Split(request, ",") 26 | for _, req := range requests { 27 | switch req { 28 | case "cpu": 29 | status["cpu"] = s.GetCpuPercent() 30 | case "mem": 31 | status["mem"] = s.GetMemInfo() 32 | case "net": 33 | status["net"] = s.GetNetInfo() 34 | case "sys": 35 | status["uptime"] = s.GetUptime() 36 | status["sys"] = s.GetSystemInfo() 37 | case "sbd": 38 | status["sbd"] = s.GetSingboxInfo() 39 | } 40 | } 41 | return &status 42 | } 43 | 44 | func (s *ServerService) GetCpuPercent() float64 { 45 | percents, err := cpu.Percent(0, false) 46 | if err != nil { 47 | logger.Warning("get cpu percent failed:", err) 48 | return 0 49 | } else { 50 | return percents[0] 51 | } 52 | } 53 | 54 | func (s *ServerService) GetUptime() uint64 { 55 | upTime, err := host.Uptime() 56 | if err != nil { 57 | logger.Warning("get uptime failed:", err) 58 | return 0 59 | } else { 60 | return upTime 61 | } 62 | } 63 | 64 | func (s *ServerService) GetMemInfo() map[string]interface{} { 65 | info := make(map[string]interface{}, 0) 66 | memInfo, err := mem.VirtualMemory() 67 | if err != nil { 68 | logger.Warning("get virtual memory failed:", err) 69 | } else { 70 | info["current"] = memInfo.Used 71 | info["total"] = memInfo.Total 72 | } 73 | return info 74 | } 75 | 76 | func (s *ServerService) GetNetInfo() map[string]interface{} { 77 | info := make(map[string]interface{}, 0) 78 | ioStats, err := net.IOCounters(false) 79 | if err != nil { 80 | logger.Warning("get io counters failed:", err) 81 | } else if len(ioStats) > 0 { 82 | ioStat := ioStats[0] 83 | info["sent"] = ioStat.BytesSent 84 | info["recv"] = ioStat.BytesRecv 85 | info["psent"] = ioStat.PacketsSent 86 | info["precv"] = ioStat.PacketsRecv 87 | } else { 88 | logger.Warning("can not find io counters") 89 | } 90 | return info 91 | } 92 | 93 | func (s *ServerService) GetSingboxInfo() map[string]interface{} { 94 | var rtm runtime.MemStats 95 | runtime.ReadMemStats(&rtm) 96 | isRunning := corePtr.IsRunning() 97 | uptime := uint32(0) 98 | if isRunning { 99 | uptime = corePtr.GetInstance().Uptime() 100 | } 101 | return map[string]interface{}{ 102 | "running": isRunning, 103 | "stats": map[string]interface{}{ 104 | "NumGoroutine": uint32(runtime.NumGoroutine()), 105 | "Alloc": rtm.Alloc, 106 | "Uptime": uptime, 107 | }, 108 | } 109 | } 110 | 111 | func (s *ServerService) GetSystemInfo() map[string]interface{} { 112 | info := make(map[string]interface{}, 0) 113 | var rtm runtime.MemStats 114 | runtime.ReadMemStats(&rtm) 115 | 116 | info["appMem"] = rtm.Sys 117 | info["appThreads"] = uint32(runtime.NumGoroutine()) 118 | cpuInfo, err := cpu.Info() 119 | if err == nil { 120 | info["cpuType"] = cpuInfo[0].ModelName 121 | } 122 | info["cpuCount"] = runtime.NumCPU() 123 | info["hostName"], _ = os.Hostname() 124 | info["appVersion"] = config.GetVersion() 125 | ipv4 := make([]string, 0) 126 | ipv6 := make([]string, 0) 127 | // get ip address 128 | netInterfaces, _ := net.Interfaces() 129 | for i := 0; i < len(netInterfaces); i++ { 130 | if len(netInterfaces[i].Flags) > 2 && netInterfaces[i].Flags[0] == "up" && netInterfaces[i].Flags[1] != "loopback" { 131 | addrs := netInterfaces[i].Addrs 132 | 133 | for _, address := range addrs { 134 | if strings.Contains(address.Addr, ".") { 135 | ipv4 = append(ipv4, address.Addr) 136 | } else if address.Addr[0:6] != "fe80::" { 137 | ipv6 = append(ipv6, address.Addr) 138 | } 139 | } 140 | } 141 | } 142 | info["ipv4"] = ipv4 143 | info["ipv6"] = ipv6 144 | 145 | return info 146 | } 147 | 148 | func (s *ServerService) GetLogs(count string, level string) []string { 149 | c, err := strconv.Atoi(count) 150 | if err != nil { 151 | c = 10 152 | } 153 | return logger.GetLogs(c, level) 154 | } 155 | 156 | func (s *ServerService) GenKeypair(keyType string, options string) []string { 157 | if len(keyType) == 0 { 158 | return []string{"No keypair to generate"} 159 | } 160 | 161 | switch keyType { 162 | case "ech": 163 | return s.generateECHKeyPair(options) 164 | case "tls": 165 | return s.generateTLSKeyPair(options) 166 | case "reality": 167 | return s.generateRealityKeyPair() 168 | case "wireguard": 169 | return s.generateWireGuardKey(options) 170 | } 171 | 172 | return []string{"Failed to generate keypair"} 173 | } 174 | 175 | func (s *ServerService) generateECHKeyPair(options string) []string { 176 | parts := strings.Split(options, ",") 177 | if len(parts) != 2 { 178 | return []string{"Failed to generate ECH keypair: ", "invalid options"} 179 | } 180 | configPem, keyPem, err := tls.ECHKeygenDefault(parts[0], parts[1] == "true") 181 | if err != nil { 182 | return []string{"Failed to generate ECH keypair: ", err.Error()} 183 | } 184 | return append(strings.Split(configPem, "\n"), strings.Split(keyPem, "\n")...) 185 | } 186 | 187 | func (s *ServerService) generateTLSKeyPair(serverName string) []string { 188 | privateKeyPem, publicKeyPem, err := tls.GenerateCertificate(nil, nil, time.Now, serverName, time.Now().AddDate(0, 12, 0)) 189 | if err != nil { 190 | return []string{"Failed to generate TLS keypair: ", err.Error()} 191 | } 192 | return append(strings.Split(string(privateKeyPem), "\n"), strings.Split(string(publicKeyPem), "\n")...) 193 | } 194 | 195 | func (s *ServerService) generateRealityKeyPair() []string { 196 | privateKey, err := wgtypes.GeneratePrivateKey() 197 | if err != nil { 198 | return []string{"Failed to generate Reality keypair: ", err.Error()} 199 | } 200 | publicKey := privateKey.PublicKey() 201 | return []string{"PrivateKey: " + base64.RawURLEncoding.EncodeToString(privateKey[:]), "PublicKey: " + base64.RawURLEncoding.EncodeToString(publicKey[:])} 202 | } 203 | 204 | func (s *ServerService) generateWireGuardKey(pk string) []string { 205 | if len(pk) > 0 { 206 | key, _ := wgtypes.ParseKey(pk) 207 | return []string{key.PublicKey().String()} 208 | } 209 | wgKeys, err := wgtypes.GeneratePrivateKey() 210 | if err != nil { 211 | return []string{"Failed to generate wireguard keypair: ", err.Error()} 212 | } 213 | return []string{"PrivateKey: " + wgKeys.String(), "PublicKey: " + wgKeys.PublicKey().String()} 214 | } 215 | -------------------------------------------------------------------------------- /service/setting.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "s-ui/config" 7 | "s-ui/database" 8 | "s-ui/database/model" 9 | "s-ui/logger" 10 | "s-ui/util/common" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "gorm.io/gorm" 16 | ) 17 | 18 | var defaultConfig = `{ 19 | "log": { 20 | "level": "info" 21 | }, 22 | "dns": {}, 23 | "route": { 24 | "rules": [ 25 | { 26 | "protocol": [ 27 | "dns" 28 | ], 29 | "action": "hijack-dns" 30 | } 31 | ] 32 | }, 33 | "experimental": {} 34 | }` 35 | 36 | var defaultValueMap = map[string]string{ 37 | "webListen": "", 38 | "webDomain": "", 39 | "webPort": "2095", 40 | "secret": common.Random(32), 41 | "webCertFile": "", 42 | "webKeyFile": "", 43 | "webPath": "/app/", 44 | "webURI": "", 45 | "sessionMaxAge": "0", 46 | "trafficAge": "30", 47 | "timeLocation": "Asia/Tehran", 48 | "subListen": "", 49 | "subPort": "2096", 50 | "subPath": "/sub/", 51 | "subDomain": "", 52 | "subCertFile": "", 53 | "subKeyFile": "", 54 | "subUpdates": "12", 55 | "subEncode": "true", 56 | "subShowInfo": "false", 57 | "subURI": "", 58 | "subJsonExt": "", 59 | "config": defaultConfig, 60 | "version": config.GetVersion(), 61 | } 62 | 63 | type SettingService struct { 64 | } 65 | 66 | func (s *SettingService) GetAllSetting() (*map[string]string, error) { 67 | db := database.GetDB() 68 | settings := make([]*model.Setting, 0) 69 | err := db.Model(model.Setting{}).Find(&settings).Error 70 | if err != nil { 71 | return nil, err 72 | } 73 | allSetting := map[string]string{} 74 | 75 | for _, setting := range settings { 76 | allSetting[setting.Key] = setting.Value 77 | } 78 | 79 | for key, defaultValue := range defaultValueMap { 80 | if _, exists := allSetting[key]; !exists { 81 | err = s.saveSetting(key, defaultValue) 82 | if err != nil { 83 | return nil, err 84 | } 85 | allSetting[key] = defaultValue 86 | } 87 | } 88 | 89 | // Due to security principles 90 | delete(allSetting, "secret") 91 | delete(allSetting, "config") 92 | delete(allSetting, "version") 93 | 94 | return &allSetting, nil 95 | } 96 | 97 | func (s *SettingService) ResetSettings() error { 98 | db := database.GetDB() 99 | return db.Where("1 = 1").Delete(model.Setting{}).Error 100 | } 101 | 102 | func (s *SettingService) getSetting(key string) (*model.Setting, error) { 103 | db := database.GetDB() 104 | setting := &model.Setting{} 105 | err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error 106 | if err != nil { 107 | return nil, err 108 | } 109 | return setting, nil 110 | } 111 | 112 | func (s *SettingService) getString(key string) (string, error) { 113 | setting, err := s.getSetting(key) 114 | if database.IsNotFound(err) { 115 | value, ok := defaultValueMap[key] 116 | if !ok { 117 | return "", common.NewErrorf("key <%v> not in defaultValueMap", key) 118 | } 119 | return value, nil 120 | } else if err != nil { 121 | return "", err 122 | } 123 | return setting.Value, nil 124 | } 125 | 126 | func (s *SettingService) saveSetting(key string, value string) error { 127 | setting, err := s.getSetting(key) 128 | db := database.GetDB() 129 | if database.IsNotFound(err) { 130 | return db.Create(&model.Setting{ 131 | Key: key, 132 | Value: value, 133 | }).Error 134 | } else if err != nil { 135 | return err 136 | } 137 | setting.Key = key 138 | setting.Value = value 139 | return db.Save(setting).Error 140 | } 141 | 142 | func (s *SettingService) setString(key string, value string) error { 143 | return s.saveSetting(key, value) 144 | } 145 | 146 | func (s *SettingService) getBool(key string) (bool, error) { 147 | str, err := s.getString(key) 148 | if err != nil { 149 | return false, err 150 | } 151 | return strconv.ParseBool(str) 152 | } 153 | 154 | // func (s *SettingService) setBool(key string, value bool) error { 155 | // return s.setString(key, strconv.FormatBool(value)) 156 | // } 157 | 158 | func (s *SettingService) getInt(key string) (int, error) { 159 | str, err := s.getString(key) 160 | if err != nil { 161 | return 0, err 162 | } 163 | return strconv.Atoi(str) 164 | } 165 | 166 | func (s *SettingService) setInt(key string, value int) error { 167 | return s.setString(key, strconv.Itoa(value)) 168 | } 169 | func (s *SettingService) GetListen() (string, error) { 170 | return s.getString("webListen") 171 | } 172 | 173 | func (s *SettingService) GetWebDomain() (string, error) { 174 | return s.getString("webDomain") 175 | } 176 | 177 | func (s *SettingService) GetPort() (int, error) { 178 | return s.getInt("webPort") 179 | } 180 | 181 | func (s *SettingService) SetPort(port int) error { 182 | return s.setInt("webPort", port) 183 | } 184 | 185 | func (s *SettingService) GetCertFile() (string, error) { 186 | return s.getString("webCertFile") 187 | } 188 | 189 | func (s *SettingService) GetKeyFile() (string, error) { 190 | return s.getString("webKeyFile") 191 | } 192 | 193 | func (s *SettingService) GetWebPath() (string, error) { 194 | webPath, err := s.getString("webPath") 195 | if err != nil { 196 | return "", err 197 | } 198 | if !strings.HasPrefix(webPath, "/") { 199 | webPath = "/" + webPath 200 | } 201 | if !strings.HasSuffix(webPath, "/") { 202 | webPath += "/" 203 | } 204 | return webPath, nil 205 | } 206 | 207 | func (s *SettingService) SetWebPath(webPath string) error { 208 | if !strings.HasPrefix(webPath, "/") { 209 | webPath = "/" + webPath 210 | } 211 | if !strings.HasSuffix(webPath, "/") { 212 | webPath += "/" 213 | } 214 | return s.setString("webPath", webPath) 215 | } 216 | 217 | func (s *SettingService) GetSecret() ([]byte, error) { 218 | secret, err := s.getString("secret") 219 | if secret == defaultValueMap["secret"] { 220 | err := s.saveSetting("secret", secret) 221 | if err != nil { 222 | logger.Warning("save secret failed:", err) 223 | } 224 | } 225 | return []byte(secret), err 226 | } 227 | 228 | func (s *SettingService) GetSessionMaxAge() (int, error) { 229 | return s.getInt("sessionMaxAge") 230 | } 231 | 232 | func (s *SettingService) GetTrafficAge() (int, error) { 233 | return s.getInt("trafficAge") 234 | } 235 | 236 | func (s *SettingService) GetTimeLocation() (*time.Location, error) { 237 | l, err := s.getString("timeLocation") 238 | if err != nil { 239 | return nil, err 240 | } 241 | location, err := time.LoadLocation(l) 242 | if err != nil { 243 | defaultLocation := defaultValueMap["timeLocation"] 244 | logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation) 245 | return time.LoadLocation(defaultLocation) 246 | } 247 | return location, nil 248 | } 249 | 250 | func (s *SettingService) GetSubListen() (string, error) { 251 | return s.getString("subListen") 252 | } 253 | 254 | func (s *SettingService) GetSubPort() (int, error) { 255 | return s.getInt("subPort") 256 | } 257 | 258 | func (s *SettingService) SetSubPort(subPort int) error { 259 | return s.setInt("subPort", subPort) 260 | } 261 | 262 | func (s *SettingService) GetSubPath() (string, error) { 263 | subPath, err := s.getString("subPath") 264 | if err != nil { 265 | return "", err 266 | } 267 | if !strings.HasPrefix(subPath, "/") { 268 | subPath = "/" + subPath 269 | } 270 | if !strings.HasSuffix(subPath, "/") { 271 | subPath += "/" 272 | } 273 | return subPath, nil 274 | } 275 | 276 | func (s *SettingService) SetSubPath(subPath string) error { 277 | if !strings.HasPrefix(subPath, "/") { 278 | subPath = "/" + subPath 279 | } 280 | if !strings.HasSuffix(subPath, "/") { 281 | subPath += "/" 282 | } 283 | return s.setString("subPath", subPath) 284 | } 285 | 286 | func (s *SettingService) GetSubDomain() (string, error) { 287 | return s.getString("subDomain") 288 | } 289 | 290 | func (s *SettingService) GetSubCertFile() (string, error) { 291 | return s.getString("subCertFile") 292 | } 293 | 294 | func (s *SettingService) GetSubKeyFile() (string, error) { 295 | return s.getString("subKeyFile") 296 | } 297 | 298 | func (s *SettingService) GetSubUpdates() (int, error) { 299 | return s.getInt("subUpdates") 300 | } 301 | 302 | func (s *SettingService) GetSubEncode() (bool, error) { 303 | return s.getBool("subEncode") 304 | } 305 | 306 | func (s *SettingService) GetSubShowInfo() (bool, error) { 307 | return s.getBool("subShowInfo") 308 | } 309 | 310 | func (s *SettingService) GetSubURI() (string, error) { 311 | return s.getString("subURI") 312 | } 313 | 314 | func (s *SettingService) GetFinalSubURI(host string) (string, error) { 315 | allSetting, err := s.GetAllSetting() 316 | if err != nil { 317 | return "", err 318 | } 319 | SubURI := (*allSetting)["subURI"] 320 | if SubURI != "" { 321 | return SubURI, nil 322 | } 323 | protocol := "http" 324 | if (*allSetting)["subKeyFile"] != "" && (*allSetting)["subCertFile"] != "" { 325 | protocol = "https" 326 | } 327 | if (*allSetting)["subDomain"] != "" { 328 | host = (*allSetting)["subDomain"] 329 | } 330 | port := ":" + (*allSetting)["subPort"] 331 | if (port == "80" && protocol == "http") || (port == "443" && protocol == "https") { 332 | port = "" 333 | } 334 | return protocol + "://" + host + port + (*allSetting)["subPath"], nil 335 | } 336 | 337 | func (s *SettingService) GetConfig() (string, error) { 338 | return s.getString("config") 339 | } 340 | 341 | func (s *SettingService) SetConfig(config string) error { 342 | return s.setString("config", config) 343 | } 344 | 345 | func (s *SettingService) SaveConfig(tx *gorm.DB, config json.RawMessage) error { 346 | configs, err := json.MarshalIndent(config, "", " ") 347 | if err != nil { 348 | return err 349 | } 350 | return tx.Model(model.Setting{}).Where("key = ?", "config").Update("value", string(configs)).Error 351 | } 352 | 353 | func (s *SettingService) Save(tx *gorm.DB, data json.RawMessage) error { 354 | var err error 355 | var settings map[string]string 356 | err = json.Unmarshal(data, &settings) 357 | if err != nil { 358 | return err 359 | } 360 | for key, obj := range settings { 361 | // Secure file existence check 362 | if obj != "" && (key == "webCertFile" || 363 | key == "webKeyFile" || 364 | key == "subCertFile" || 365 | key == "subKeyFile") { 366 | err = s.fileExists(obj) 367 | if err != nil { 368 | return common.NewError(" -> ", obj, " is not exists") 369 | } 370 | } 371 | 372 | // Correct Pathes start and ends with `/` 373 | if key == "webPath" || 374 | key == "subPath" { 375 | if !strings.HasPrefix(obj, "/") { 376 | obj = "/" + obj 377 | } 378 | if !strings.HasSuffix(obj, "/") { 379 | obj += "/" 380 | } 381 | } 382 | 383 | err = tx.Model(model.Setting{}).Where("key = ?", key).Update("value", obj).Error 384 | if err != nil { 385 | return err 386 | } 387 | } 388 | return err 389 | } 390 | 391 | func (s *SettingService) GetSubJsonExt() (string, error) { 392 | return s.getString("subJsonExt") 393 | } 394 | 395 | func (s *SettingService) fileExists(path string) error { 396 | _, err := os.Stat(path) 397 | return err 398 | } 399 | -------------------------------------------------------------------------------- /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() error { 23 | if !corePtr.IsRunning() { 24 | return nil 25 | } 26 | stats := corePtr.GetInstance().ConnTracker().GetStats() 27 | 28 | // Reset onlines 29 | onlineResources.Inbound = nil 30 | onlineResources.Outbound = nil 31 | onlineResources.User = nil 32 | 33 | if len(*stats) == 0 { 34 | return nil 35 | } 36 | 37 | var err error 38 | db := database.GetDB() 39 | tx := db.Begin() 40 | defer func() { 41 | if err == nil { 42 | tx.Commit() 43 | } else { 44 | tx.Rollback() 45 | } 46 | }() 47 | 48 | for _, stat := range *stats { 49 | if stat.Resource == "user" { 50 | if stat.Direction { 51 | err = tx.Model(model.Client{}).Where("name = ?", stat.Tag). 52 | UpdateColumn("up", gorm.Expr("up + ?", stat.Traffic)).Error 53 | } else { 54 | err = tx.Model(model.Client{}).Where("name = ?", stat.Tag). 55 | UpdateColumn("down", gorm.Expr("down + ?", stat.Traffic)).Error 56 | } 57 | if err != nil { 58 | return err 59 | } 60 | } 61 | if stat.Direction { 62 | switch stat.Resource { 63 | case "inbound": 64 | onlineResources.Inbound = append(onlineResources.Inbound, stat.Tag) 65 | case "outbound": 66 | onlineResources.Outbound = append(onlineResources.Outbound, stat.Tag) 67 | case "user": 68 | onlineResources.User = append(onlineResources.User, stat.Tag) 69 | } 70 | } 71 | } 72 | 73 | err = tx.Create(&stats).Error 74 | return err 75 | } 76 | 77 | func (s *StatsService) GetStats(resource string, tag string, limit int) ([]model.Stats, error) { 78 | var err error 79 | var result []model.Stats 80 | 81 | currentTime := time.Now().Unix() 82 | timeDiff := currentTime - (int64(limit) * 3600) 83 | 84 | db := database.GetDB() 85 | resources := []string{resource} 86 | if resource == "endpoint" { 87 | resources = []string{"inbound", "outbound"} 88 | } 89 | err = db.Model(model.Stats{}).Where("resource in ? AND tag = ? AND date_time > ?", resources, tag, timeDiff).Scan(&result).Error 90 | if err != nil { 91 | return nil, err 92 | } 93 | return result, nil 94 | } 95 | 96 | func (s *StatsService) GetOnlines() (onlines, error) { 97 | return *onlineResources, nil 98 | } 99 | func (s *StatsService) DelOldStats(days int) error { 100 | oldTime := time.Now().AddDate(0, 0, -(days)).Unix() 101 | db := database.GetDB() 102 | return db.Where("date_time < ?", oldTime).Delete(model.Stats{}).Error 103 | } 104 | -------------------------------------------------------------------------------- /service/tls.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "s-ui/database" 6 | "s-ui/database/model" 7 | "s-ui/util/common" 8 | 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type TlsService struct { 13 | InboundService 14 | } 15 | 16 | func (s *TlsService) GetAll() ([]model.Tls, error) { 17 | db := database.GetDB() 18 | tlsConfig := []model.Tls{} 19 | err := db.Model(model.Tls{}).Scan(&tlsConfig).Error 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return tlsConfig, nil 25 | } 26 | 27 | func (s *TlsService) Save(tx *gorm.DB, action string, data json.RawMessage) ([]uint, error) { 28 | var err error 29 | var inboundIds []uint 30 | 31 | switch action { 32 | case "new", "edit": 33 | var tls model.Tls 34 | err = json.Unmarshal(data, &tls) 35 | if err != nil { 36 | return nil, err 37 | } 38 | err = tx.Save(&tls).Error 39 | if err != nil { 40 | return nil, err 41 | } 42 | err = tx.Model(model.Inbound{}).Select("id").Where("tls_id = ?", tls.Id).Scan(&inboundIds).Error 43 | if err != nil { 44 | return nil, err 45 | } 46 | return inboundIds, nil 47 | case "del": 48 | var id uint 49 | err = json.Unmarshal(data, &id) 50 | if err != nil { 51 | return nil, err 52 | } 53 | var inboundCount int64 54 | err = tx.Model(model.Inbound{}).Where("tls_id = ?", id).Count(&inboundCount).Error 55 | if err != nil { 56 | return nil, err 57 | } 58 | if inboundCount > 0 { 59 | return nil, common.NewError("tls in use") 60 | } 61 | err = tx.Where("id = ?", id).Delete(model.Tls{}).Error 62 | if err != nil { 63 | return nil, err 64 | } 65 | } 66 | 67 | return nil, nil 68 | } 69 | -------------------------------------------------------------------------------- /service/user.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "s-ui/database" 6 | "s-ui/database/model" 7 | "s-ui/logger" 8 | "s-ui/util/common" 9 | "time" 10 | ) 11 | 12 | type UserService struct { 13 | } 14 | 15 | func (s *UserService) GetFirstUser() (*model.User, error) { 16 | db := database.GetDB() 17 | 18 | user := &model.User{} 19 | err := db.Model(model.User{}). 20 | First(user). 21 | Error 22 | if err != nil { 23 | return nil, err 24 | } 25 | return user, nil 26 | } 27 | 28 | func (s *UserService) UpdateFirstUser(username string, password string) error { 29 | if username == "" { 30 | return common.NewError("username can not be empty") 31 | } else if password == "" { 32 | return common.NewError("password can not be empty") 33 | } 34 | db := database.GetDB() 35 | user := &model.User{} 36 | err := db.Model(model.User{}).First(user).Error 37 | if database.IsNotFound(err) { 38 | user.Username = username 39 | user.Password = password 40 | return db.Model(model.User{}).Create(user).Error 41 | } else if err != nil { 42 | return err 43 | } 44 | user.Username = username 45 | user.Password = password 46 | return db.Save(user).Error 47 | } 48 | 49 | func (s *UserService) Login(username string, password string, remoteIP string) (string, error) { 50 | user := s.CheckUser(username, password, remoteIP) 51 | if user == nil { 52 | return "", common.NewError("wrong user or password! IP: ", remoteIP) 53 | } 54 | return user.Username, nil 55 | } 56 | 57 | func (s *UserService) CheckUser(username string, password string, remoteIP string) *model.User { 58 | db := database.GetDB() 59 | 60 | user := &model.User{} 61 | err := db.Model(model.User{}). 62 | Where("username = ? and password = ?", username, password). 63 | First(user). 64 | Error 65 | if database.IsNotFound(err) { 66 | return nil 67 | } else if err != nil { 68 | logger.Warning("check user err:", err, " IP: ", remoteIP) 69 | return nil 70 | } 71 | 72 | lastLoginTxt := time.Now().Format("2006-01-02 15:04:05") + " " + remoteIP 73 | err = db.Model(model.User{}). 74 | Where("username = ?", username). 75 | Update("last_logins", &lastLoginTxt).Error 76 | if err != nil { 77 | logger.Warning("unable to log login data", err) 78 | } 79 | return user 80 | } 81 | 82 | func (s *UserService) GetUsers() (*[]model.User, error) { 83 | var users []model.User 84 | db := database.GetDB() 85 | err := db.Model(model.User{}).Select("id,username,last_logins").Scan(&users).Error 86 | if err != nil { 87 | return nil, err 88 | } 89 | return &users, nil 90 | } 91 | 92 | func (s *UserService) ChangePass(id string, oldPass string, newUser string, newPass string) error { 93 | db := database.GetDB() 94 | user := &model.User{} 95 | err := db.Model(model.User{}).Where("id = ? AND password = ?", id, oldPass).First(user).Error 96 | if err != nil || database.IsNotFound(err) { 97 | return err 98 | } 99 | user.Username = newUser 100 | user.Password = newPass 101 | return db.Save(user).Error 102 | } 103 | 104 | func (s *UserService) LoadTokens() ([]byte, error) { 105 | db := database.GetDB() 106 | var tokens []model.Tokens 107 | err := db.Model(model.Tokens{}).Preload("User").Where("expiry == 0 or expiry > ?", time.Now().Unix()).Find(&tokens).Error 108 | if err != nil { 109 | return nil, err 110 | } 111 | var result []map[string]interface{} 112 | for _, t := range tokens { 113 | result = append(result, map[string]interface{}{ 114 | "token": t.Token, 115 | "expiry": t.Expiry, 116 | "username": t.User.Username, 117 | }) 118 | } 119 | jsonResult, _ := json.MarshalIndent(result, "", " ") 120 | return jsonResult, nil 121 | } 122 | 123 | func (s *UserService) GetUserTokens(username string) (*[]model.Tokens, error) { 124 | db := database.GetDB() 125 | var token []model.Tokens 126 | err := db.Model(model.Tokens{}).Select("id,desc,'****' as token,expiry,user_id").Where("user_id = (select id from users where username = ?)", username).Find(&token).Error 127 | if err != nil && !database.IsNotFound(err) { 128 | println(err.Error()) 129 | return nil, err 130 | } 131 | return &token, nil 132 | } 133 | 134 | func (s *UserService) AddToken(username string, expiry int64, desc string) (string, error) { 135 | db := database.GetDB() 136 | var userId uint 137 | err := db.Model(model.User{}).Where("username = ?", username).Select("id").Scan(&userId).Error 138 | if err != nil { 139 | return "", err 140 | } 141 | if expiry > 0 { 142 | expiry = expiry*86400 + time.Now().Unix() 143 | } 144 | token := &model.Tokens{ 145 | Token: common.Random(32), 146 | Desc: desc, 147 | Expiry: expiry, 148 | UserId: userId, 149 | } 150 | err = db.Create(token).Error 151 | if err != nil { 152 | return "", err 153 | } 154 | return token.Token, nil 155 | } 156 | 157 | func (s *UserService) DeleteToken(id string) error { 158 | db := database.GetDB() 159 | return db.Model(model.Tokens{}).Where("id = ?", id).Delete(&model.Tokens{}).Error 160 | } 161 | -------------------------------------------------------------------------------- /service/warp.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "os" 11 | "s-ui/database/model" 12 | "s-ui/logger" 13 | "s-ui/util/common" 14 | "strconv" 15 | "time" 16 | 17 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes" 18 | ) 19 | 20 | type WarpService struct{} 21 | 22 | func (s *WarpService) getWarpInfo(deviceId string, accessToken string) ([]byte, error) { 23 | url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s", deviceId) 24 | 25 | req, err := http.NewRequest("GET", url, nil) 26 | if err != nil { 27 | return nil, err 28 | } 29 | req.Header.Set("Authorization", "Bearer "+accessToken) 30 | 31 | client := &http.Client{} 32 | resp, err := client.Do(req) 33 | if err != nil || resp.StatusCode != 200 { 34 | return nil, err 35 | } 36 | defer resp.Body.Close() 37 | buffer := bytes.NewBuffer(make([]byte, 8192)) 38 | buffer.Reset() 39 | _, err = buffer.ReadFrom(resp.Body) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return buffer.Bytes(), nil 45 | } 46 | 47 | func (s *WarpService) RegisterWarp(ep *model.Endpoint) error { 48 | tos := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") 49 | privateKey, _ := wgtypes.GenerateKey() 50 | publicKey := privateKey.PublicKey().String() 51 | hostName, _ := os.Hostname() 52 | 53 | data := fmt.Sprintf(`{"key":"%s","tos":"%s","type": "PC","model": "s-ui", "name": "%s"}`, publicKey, tos, hostName) 54 | url := "https://api.cloudflareclient.com/v0a2158/reg" 55 | 56 | req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(data))) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | req.Header.Add("CF-Client-Version", "a-7.21-0721") 62 | req.Header.Add("Content-Type", "application/json") 63 | 64 | client := &http.Client{} 65 | resp, err := client.Do(req) 66 | if err != nil || resp.StatusCode != 200 { 67 | return err 68 | } 69 | defer resp.Body.Close() 70 | buffer := bytes.NewBuffer(make([]byte, 8192)) 71 | buffer.Reset() 72 | _, err = buffer.ReadFrom(resp.Body) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | var rspData map[string]interface{} 78 | err = json.Unmarshal(buffer.Bytes(), &rspData) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | deviceId := rspData["id"].(string) 84 | token := rspData["token"].(string) 85 | license, ok := rspData["account"].(map[string]interface{})["license"].(string) 86 | if !ok { 87 | logger.Debug("Error accessing license value.") 88 | return err 89 | } 90 | 91 | warpInfo, err := s.getWarpInfo(deviceId, token) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | var warpDetails map[string]interface{} 97 | err = json.Unmarshal(warpInfo, &warpDetails) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | warpConfig, _ := warpDetails["config"].(map[string]interface{}) 103 | clientId, _ := warpConfig["client_id"].(string) 104 | reserved := s.getReserved(clientId) 105 | interfaceConfig, _ := warpConfig["interface"].(map[string]interface{}) 106 | addresses, _ := interfaceConfig["addresses"].(map[string]interface{}) 107 | v4, _ := addresses["v4"].(string) 108 | v6, _ := addresses["v6"].(string) 109 | peer, _ := warpConfig["peers"].([]interface{})[0].(map[string]interface{}) 110 | peerEndpoint, _ := peer["endpoint"].(map[string]interface{})["host"].(string) 111 | peerEpAddress, peerEpPort, err := net.SplitHostPort(peerEndpoint) 112 | if err != nil { 113 | return err 114 | } 115 | peerPublicKey, _ := peer["public_key"].(string) 116 | peerPort, _ := strconv.Atoi(peerEpPort) 117 | 118 | peers := []map[string]interface{}{ 119 | { 120 | "address": peerEpAddress, 121 | "port": peerPort, 122 | "public_key": peerPublicKey, 123 | "allowed_ips": []string{"0.0.0.0/0", "::/0"}, 124 | "reserved": reserved, 125 | }, 126 | } 127 | 128 | warpData := map[string]interface{}{ 129 | "access_token": token, 130 | "device_id": deviceId, 131 | "license_key": license, 132 | } 133 | 134 | ep.Ext, err = json.MarshalIndent(warpData, "", " ") 135 | if err != nil { 136 | return err 137 | } 138 | 139 | var epOptions map[string]interface{} 140 | err = json.Unmarshal(ep.Options, &epOptions) 141 | if err != nil { 142 | return err 143 | } 144 | epOptions["private_key"] = privateKey.String() 145 | epOptions["address"] = []string{fmt.Sprintf("%s/32", v4), fmt.Sprintf("%s/128", v6)} 146 | epOptions["listen_port"] = 0 147 | epOptions["peers"] = peers 148 | 149 | ep.Options, err = json.MarshalIndent(epOptions, "", " ") 150 | return err 151 | } 152 | 153 | func (s *WarpService) getReserved(clientID string) []int { 154 | var reserved []int 155 | decoded, err := base64.StdEncoding.DecodeString(clientID) 156 | if err != nil { 157 | return nil 158 | } 159 | 160 | hexString := "" 161 | for _, char := range decoded { 162 | hex := fmt.Sprintf("%02x", char) 163 | hexString += hex 164 | } 165 | 166 | for i := 0; i < len(hexString); i += 2 { 167 | hexByte := hexString[i : i+2] 168 | decValue, err := strconv.ParseInt(hexByte, 16, 32) 169 | if err != nil { 170 | return nil 171 | } 172 | reserved = append(reserved, int(decValue)) 173 | } 174 | 175 | return reserved 176 | } 177 | 178 | func (s *WarpService) SetWarpLicense(old_license string, ep *model.Endpoint) error { 179 | var warpData map[string]string 180 | err := json.Unmarshal(ep.Ext, &warpData) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | if warpData["license_key"] == old_license { 186 | return nil 187 | } 188 | 189 | url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s/account", warpData["device_id"]) 190 | data := fmt.Sprintf(`{"license": "%s"}`, warpData["license_key"]) 191 | 192 | req, err := http.NewRequest("PUT", url, bytes.NewBuffer([]byte(data))) 193 | if err != nil { 194 | return err 195 | } 196 | req.Header.Set("Authorization", "Bearer "+warpData["access_token"]) 197 | 198 | client := &http.Client{} 199 | resp, err := client.Do(req) 200 | if err != nil { 201 | return err 202 | } 203 | defer resp.Body.Close() 204 | buffer := bytes.NewBuffer(make([]byte, 8192)) 205 | buffer.Reset() 206 | _, err = buffer.ReadFrom(resp.Body) 207 | if err != nil { 208 | return err 209 | } 210 | var response map[string]interface{} 211 | err = json.Unmarshal(buffer.Bytes(), &response) 212 | if err != nil { 213 | return err 214 | } 215 | 216 | if success, ok := response["success"].(bool); ok && success == false { 217 | errorArr, _ := response["errors"].([]interface{}) 218 | errorObj := errorArr[0].(map[string]interface{}) 219 | return common.NewError(errorObj["code"], errorObj["message"]) 220 | } 221 | 222 | return nil 223 | } 224 | -------------------------------------------------------------------------------- /sub/jsonService.go: -------------------------------------------------------------------------------- 1 | package sub 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "s-ui/database" 7 | "s-ui/database/model" 8 | "s-ui/service" 9 | "s-ui/util" 10 | ) 11 | 12 | const defaultJson = ` 13 | { 14 | "inbounds": [ 15 | { 16 | "type": "tun", 17 | "address": [ 18 | "172.19.0.1/30", 19 | "fdfe:dcba:9876::1/126" 20 | ], 21 | "mtu": 9000, 22 | "auto_route": true, 23 | "strict_route": false, 24 | "endpoint_independent_nat": false, 25 | "stack": "system", 26 | "platform": { 27 | "http_proxy": { 28 | "enabled": true, 29 | "server": "127.0.0.1", 30 | "server_port": 2080 31 | } 32 | } 33 | }, 34 | { 35 | "type": "mixed", 36 | "listen": "127.0.0.1", 37 | "listen_port": 2080, 38 | "users": [] 39 | } 40 | ] 41 | } 42 | ` 43 | 44 | type JsonService struct { 45 | service.SettingService 46 | LinkService 47 | } 48 | 49 | func (j *JsonService) GetJson(subId string, format string) (*string, error) { 50 | var jsonConfig map[string]interface{} 51 | 52 | client, inDatas, err := j.getData(subId) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | outbounds, outTags, err := j.getOutbounds(client.Config, inDatas) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | links := j.LinkService.GetLinks(&client.Links, "external", "") 63 | for index, link := range links { 64 | json, tag, err := util.GetOutbound(link, index) 65 | if err == nil && len(tag) > 0 { 66 | *outbounds = append(*outbounds, *json) 67 | *outTags = append(*outTags, tag) 68 | } 69 | } 70 | 71 | j.addDefaultOutbounds(outbounds, outTags) 72 | 73 | err = json.Unmarshal([]byte(defaultJson), &jsonConfig) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | jsonConfig["outbounds"] = outbounds 79 | 80 | // Add other objects from settings 81 | j.addOthers(&jsonConfig) 82 | 83 | result, _ := json.MarshalIndent(jsonConfig, "", " ") 84 | resultStr := string(result) 85 | return &resultStr, nil 86 | } 87 | 88 | func (j *JsonService) getData(subId string) (*model.Client, []*model.Inbound, error) { 89 | db := database.GetDB() 90 | client := &model.Client{} 91 | err := db.Model(model.Client{}).Where("enable = true and name = ?", subId).First(client).Error 92 | if err != nil { 93 | return nil, nil, err 94 | } 95 | var clientInbounds []uint 96 | err = json.Unmarshal(client.Inbounds, &clientInbounds) 97 | if err != nil { 98 | return nil, nil, err 99 | } 100 | var inbounds []*model.Inbound 101 | err = db.Model(model.Inbound{}).Preload("Tls").Where("id in ?", clientInbounds).Find(&inbounds).Error 102 | if err != nil { 103 | return nil, nil, err 104 | } 105 | return client, inbounds, nil 106 | } 107 | 108 | func (j *JsonService) getOutbounds(clientConfig json.RawMessage, inbounds []*model.Inbound) (*[]map[string]interface{}, *[]string, error) { 109 | var outbounds []map[string]interface{} 110 | var configs map[string]interface{} 111 | var outTags []string 112 | 113 | err := json.Unmarshal(clientConfig, &configs) 114 | if err != nil { 115 | return nil, nil, err 116 | } 117 | for _, inData := range inbounds { 118 | if len(inData.OutJson) < 5 { 119 | continue 120 | } 121 | var outbound map[string]interface{} 122 | err = json.Unmarshal(inData.OutJson, &outbound) 123 | if err != nil { 124 | return nil, nil, err 125 | } 126 | protocol, _ := outbound["type"].(string) 127 | config, _ := configs[protocol].(map[string]interface{}) 128 | for key, value := range config { 129 | if key == "name" || key == "alterId" || (key == "flow" && inData.TlsId == 0) { 130 | continue 131 | } 132 | outbound[key] = value 133 | } 134 | 135 | var addrs []map[string]interface{} 136 | err = json.Unmarshal(inData.Addrs, &addrs) 137 | if err != nil { 138 | return nil, nil, err 139 | } 140 | tag, _ := outbound["tag"].(string) 141 | if len(addrs) == 0 { 142 | // For mixed protocol, use separated socks and http 143 | if protocol == "mixed" { 144 | outbound["tag"] = tag 145 | j.pushMixed(&outbounds, &outTags, outbound) 146 | } else { 147 | outTags = append(outTags, tag) 148 | outbounds = append(outbounds, outbound) 149 | } 150 | } else { 151 | for index, addr := range addrs { 152 | // Copy original config 153 | newOut := make(map[string]interface{}, len(outbound)) 154 | for key, value := range outbound { 155 | newOut[key] = value 156 | } 157 | // Change and push copied config 158 | newOut["server"], _ = addr["server"].(string) 159 | port, _ := addr["server_port"].(float64) 160 | newOut["server_port"] = int(port) 161 | 162 | // Override TLS 163 | if addrTls, ok := addr["tls"].(map[string]interface{}); ok { 164 | outTls, _ := newOut["tls"].(map[string]interface{}) 165 | if outTls == nil { 166 | outTls = make(map[string]interface{}) 167 | } 168 | for key, value := range addrTls { 169 | outTls[key] = value 170 | } 171 | newOut["tls"] = outTls 172 | } 173 | 174 | remark, _ := addr["remark"].(string) 175 | newTag := fmt.Sprintf("%d.%s%s", index+1, tag, remark) 176 | newOut["tag"] = newTag 177 | // For mixed protocol, use separated socks and http 178 | if protocol == "mixed" { 179 | j.pushMixed(&outbounds, &outTags, newOut) 180 | } else { 181 | outTags = append(outTags, newTag) 182 | outbounds = append(outbounds, newOut) 183 | } 184 | } 185 | } 186 | } 187 | return &outbounds, &outTags, nil 188 | } 189 | 190 | func (j *JsonService) addDefaultOutbounds(outbounds *[]map[string]interface{}, outTags *[]string) { 191 | outbound := []map[string]interface{}{ 192 | { 193 | "outbounds": append([]string{"auto", "direct"}, *outTags...), 194 | "tag": "proxy", 195 | "type": "selector", 196 | }, 197 | { 198 | "tag": "auto", 199 | "type": "urltest", 200 | "outbounds": outTags, 201 | "url": "http://www.gstatic.com/generate_204", 202 | "interval": "10m", 203 | "tolerance": 50, 204 | }, 205 | { 206 | "type": "direct", 207 | "tag": "direct", 208 | }, 209 | } 210 | *outbounds = append(outbound, *outbounds...) 211 | } 212 | 213 | func (j *JsonService) addOthers(jsonConfig *map[string]interface{}) error { 214 | rules := []interface{}{ 215 | map[string]interface{}{ 216 | "action": "sniff", 217 | }, 218 | map[string]interface{}{ 219 | "clash_mode": "Direct", 220 | "action": "route", 221 | "outbound": "direct", 222 | }, 223 | map[string]interface{}{ 224 | "clash_mode": "Global", 225 | "action": "route", 226 | "outbound": "proxy", 227 | }, 228 | } 229 | route := map[string]interface{}{ 230 | "auto_detect_interface": true, 231 | "final": "proxy", 232 | "rules": rules, 233 | } 234 | 235 | othersStr, err := j.SettingService.GetSubJsonExt() 236 | if err != nil { 237 | return err 238 | } 239 | if len(othersStr) == 0 { 240 | (*jsonConfig)["route"] = route 241 | return nil 242 | } 243 | var othersJson map[string]interface{} 244 | err = json.Unmarshal([]byte(othersStr), &othersJson) 245 | if err != nil { 246 | return err 247 | } 248 | if _, ok := othersJson["log"]; ok { 249 | (*jsonConfig)["log"] = othersJson["log"] 250 | } 251 | if _, ok := othersJson["dns"]; ok { 252 | (*jsonConfig)["dns"] = othersJson["dns"] 253 | } 254 | if _, ok := othersJson["inbounds"]; ok { 255 | (*jsonConfig)["inbounds"] = othersJson["inbounds"] 256 | } 257 | if _, ok := othersJson["experimental"]; ok { 258 | (*jsonConfig)["experimental"] = othersJson["experimental"] 259 | } 260 | if _, ok := othersJson["rule_set"]; ok { 261 | route["rule_set"] = othersJson["rule_set"] 262 | } 263 | if settingRules, ok := othersJson["rules"].([]interface{}); ok { 264 | route["rules"] = append(rules, settingRules...) 265 | } 266 | (*jsonConfig)["route"] = route 267 | 268 | return nil 269 | } 270 | 271 | func (j *JsonService) pushMixed(outbounds *[]map[string]interface{}, outTags *[]string, out map[string]interface{}) { 272 | socksOut := make(map[string]interface{}, 1) 273 | httpOut := make(map[string]interface{}, 1) 274 | for key, value := range out { 275 | socksOut[key] = value 276 | httpOut[key] = value 277 | } 278 | socksTag := fmt.Sprintf("%s-socks", out["tag"]) 279 | httpTag := fmt.Sprintf("%s-http", out["tag"]) 280 | socksOut["type"] = "socks" 281 | httpOut["type"] = "http" 282 | socksOut["tag"] = socksTag 283 | httpOut["tag"] = httpTag 284 | *outbounds = append(*outbounds, socksOut, httpOut) 285 | *outTags = append(*outTags, socksTag, httpTag) 286 | } 287 | -------------------------------------------------------------------------------- /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 | if len(clientInfo) == 0 { 46 | return uri 47 | } 48 | protocol := strings.Split(uri, "://") 49 | if len(protocol) < 2 { 50 | return uri 51 | } 52 | switch protocol[0] { 53 | case "vmess": 54 | var vmessJson map[string]interface{} 55 | config, err := util.B64StrToByte(protocol[1]) 56 | if err != nil { 57 | logger.Warning("sub: Error decoding vmess content:", err) 58 | return uri 59 | } 60 | err = json.Unmarshal(config, &vmessJson) 61 | if err != nil { 62 | logger.Warning("sub: Error decoding vmess content:", err) 63 | return uri 64 | } 65 | vmessJson["ps"] = vmessJson["ps"].(string) + clientInfo 66 | result, err := json.MarshalIndent(vmessJson, "", " ") 67 | if err != nil { 68 | logger.Warning("sub: Error decoding vmess + clientInfo content:", err) 69 | return uri 70 | } 71 | return "vmess://" + util.ByteToB64Str(result) 72 | default: 73 | return uri + clientInfo 74 | } 75 | } 76 | 77 | func (s *LinkService) getExternalSub(url string) []string { 78 | tr := &http.Transport{ 79 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 80 | } 81 | 82 | client := &http.Client{Transport: tr} 83 | 84 | // Make the HTTP request 85 | response, err := client.Get(url) 86 | if err != nil { 87 | logger.Warning("sub: Error making HTTP request:", err) 88 | return nil 89 | } 90 | defer response.Body.Close() 91 | 92 | // Read the response body 93 | body, err := io.ReadAll(response.Body) 94 | if err != nil { 95 | logger.Warning("sub: Error reading response body:", err) 96 | return nil 97 | } 98 | 99 | // Convert if the content is Base64 encoded 100 | links := util.StrOrBase64Encoded(string(body)) 101 | return strings.Split(links, "\n") 102 | 103 | } 104 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 28 | func RandomInt(n int) int { 29 | return rnd.Intn(n) 30 | } 31 | -------------------------------------------------------------------------------- /util/outJson.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "math/rand" 6 | "s-ui/database/model" 7 | ) 8 | 9 | // Fill Inbound's out_json 10 | func FillOutJson(i *model.Inbound, hostname string) error { 11 | switch i.Type { 12 | case "direct", "tun", "redirect", "tproxy": 13 | return nil 14 | } 15 | var outJson map[string]interface{} 16 | err := json.Unmarshal(i.OutJson, &outJson) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | if outJson == nil { 22 | outJson = make(map[string]interface{}) 23 | } 24 | 25 | if i.TlsId > 0 { 26 | addTls(&outJson, i.Tls) 27 | } else { 28 | delete(outJson, "tls") 29 | } 30 | 31 | inbound, err := i.MarshalFull() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | outJson["type"] = i.Type 37 | outJson["tag"] = i.Tag 38 | outJson["server"] = hostname 39 | outJson["server_port"] = (*inbound)["listen_port"] 40 | 41 | switch i.Type { 42 | case "http", "socks", "mixed": 43 | case "shadowsocks": 44 | shadowsocksOut(&outJson, *inbound) 45 | return nil 46 | case "shadowtls": 47 | shadowTlsOut(&outJson, *inbound) 48 | case "hysteria": 49 | hysteriaOut(&outJson, *inbound) 50 | case "hysteria2": 51 | hysteria2Out(&outJson, *inbound) 52 | case "tuic": 53 | tuicOut(&outJson, *inbound) 54 | case "vless": 55 | vlessOut(&outJson, *inbound) 56 | case "trojan": 57 | trojanOut(&outJson, *inbound) 58 | case "vmess": 59 | vmessOut(&outJson, *inbound) 60 | default: 61 | for key := range outJson { 62 | delete(outJson, key) 63 | } 64 | } 65 | 66 | i.OutJson, err = json.MarshalIndent(outJson, "", " ") 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // addTls function 75 | func addTls(out *map[string]interface{}, tls *model.Tls) { 76 | var tlsServer, tlsConfig map[string]interface{} 77 | err := json.Unmarshal(tls.Server, &tlsServer) 78 | if err != nil { 79 | return 80 | } 81 | err = json.Unmarshal(tls.Client, &tlsConfig) 82 | if err != nil { 83 | return 84 | } 85 | 86 | if enabled, ok := tlsServer["enabled"]; ok { 87 | tlsConfig["enabled"] = enabled 88 | } 89 | if serverName, ok := tlsServer["server_name"]; ok { 90 | tlsConfig["server_name"] = serverName 91 | } 92 | if alpn, ok := tlsServer["alpn"]; ok { 93 | tlsConfig["alpn"] = alpn 94 | } 95 | if minVersion, ok := tlsServer["min_version"]; ok { 96 | tlsConfig["min_version"] = minVersion 97 | } 98 | if maxVersion, ok := tlsServer["max_version"]; ok { 99 | tlsConfig["max_version"] = maxVersion 100 | } 101 | if cipherSuites, ok := tlsServer["cipher_suites"]; ok { 102 | tlsConfig["cipher_suites"] = cipherSuites 103 | } 104 | if reality, ok := tlsServer["reality"].(map[string]interface{}); ok && reality["enabled"].(bool) { 105 | realityConfig := tlsConfig["reality"].(map[string]interface{}) 106 | realityConfig["enabled"] = true 107 | if shortIDs, ok := reality["short_id"].([]interface{}); ok && len(shortIDs) > 0 { 108 | realityConfig["short_id"] = shortIDs[rand.Intn(len(shortIDs))] 109 | } 110 | tlsConfig["reality"] = realityConfig 111 | } 112 | if ech, ok := tlsServer["ech"].(map[string]interface{}); ok && ech["enabled"].(bool) { 113 | echConfig := tlsConfig["ech"].(map[string]interface{}) 114 | echConfig["enabled"] = true 115 | echConfig["pq_signature_schemes_enabled"] = ech["pq_signature_schemes_enabled"] 116 | echConfig["dynamic_record_sizing_disabled"] = ech["dynamic_record_sizing_disabled"] 117 | tlsConfig["ech"] = echConfig 118 | } 119 | 120 | (*out)["tls"] = tlsConfig 121 | } 122 | 123 | // Protocol-specific functions 124 | func shadowsocksOut(out *map[string]interface{}, inbound map[string]interface{}) { 125 | if method, ok := inbound["method"].(string); ok { 126 | (*out)["method"] = method 127 | } 128 | } 129 | 130 | func shadowTlsOut(out *map[string]interface{}, inbound map[string]interface{}) { 131 | if version, ok := inbound["version"].(float64); ok && int(version) == 3 { 132 | (*out)["version"] = 3 133 | } else { 134 | for key := range *out { 135 | delete(*out, key) 136 | } 137 | } 138 | (*out)["tls"] = map[string]interface{}{"enabled": true} 139 | } 140 | 141 | func hysteriaOut(out *map[string]interface{}, inbound map[string]interface{}) { 142 | delete(*out, "down_mbps") 143 | delete(*out, "up_mbps") 144 | delete(*out, "obfs") 145 | delete(*out, "recv_window_conn") 146 | delete(*out, "disable_mtu_discovery") 147 | 148 | if upMbps, ok := inbound["down_mbps"]; ok { 149 | (*out)["up_mbps"] = upMbps 150 | } 151 | if downMbps, ok := inbound["up_mbps"]; ok { 152 | (*out)["down_mbps"] = downMbps 153 | } 154 | if obfs, ok := inbound["obfs"]; ok { 155 | (*out)["obfs"] = obfs 156 | } 157 | if recvWindow, ok := inbound["recv_window_conn"]; ok { 158 | (*out)["recv_window_conn"] = recvWindow 159 | } 160 | if disableMTU, ok := inbound["disable_mtu_discovery"]; ok { 161 | (*out)["disable_mtu_discovery"] = disableMTU 162 | } 163 | } 164 | 165 | func hysteria2Out(out *map[string]interface{}, inbound map[string]interface{}) { 166 | delete(*out, "down_mbps") 167 | delete(*out, "up_mbps") 168 | delete(*out, "obfs") 169 | 170 | if upMbps, ok := inbound["down_mbps"]; ok { 171 | (*out)["up_mbps"] = upMbps 172 | } 173 | if downMbps, ok := inbound["up_mbps"]; ok { 174 | (*out)["down_mbps"] = downMbps 175 | } 176 | if obfs, ok := inbound["obfs"]; ok { 177 | (*out)["obfs"] = obfs 178 | } 179 | } 180 | 181 | func tuicOut(out *map[string]interface{}, inbound map[string]interface{}) { 182 | delete(*out, "zero_rtt_handshake") 183 | delete(*out, "heartbeat") 184 | if congestionControl, ok := inbound["congestion_control"].(string); ok { 185 | (*out)["congestion_control"] = congestionControl 186 | } else { 187 | (*out)["congestion_control"] = "cubic" 188 | } 189 | if zeroRTT, ok := inbound["zero_rtt_handshake"].(bool); ok { 190 | (*out)["zero_rtt_handshake"] = zeroRTT 191 | } 192 | if heartbeat, ok := inbound["heartbeat"]; ok { 193 | (*out)["heartbeat"] = heartbeat 194 | } 195 | } 196 | 197 | func vlessOut(out *map[string]interface{}, inbound map[string]interface{}) { 198 | delete(*out, "transport") 199 | if transport, ok := inbound["transport"]; ok { 200 | (*out)["transport"] = transport 201 | } 202 | } 203 | 204 | func trojanOut(out *map[string]interface{}, inbound map[string]interface{}) { 205 | delete(*out, "transport") 206 | if transport, ok := inbound["transport"]; ok { 207 | (*out)["transport"] = transport 208 | } 209 | } 210 | 211 | func vmessOut(out *map[string]interface{}, inbound map[string]interface{}) { 212 | delete(*out, "transport") 213 | if transport, ok := inbound["transport"]; ok { 214 | (*out)["transport"] = transport 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "embed" 7 | "html/template" 8 | "io" 9 | "io/fs" 10 | "net" 11 | "net/http" 12 | "s-ui/api" 13 | "s-ui/config" 14 | "s-ui/logger" 15 | "s-ui/middleware" 16 | "s-ui/network" 17 | "s-ui/service" 18 | "strconv" 19 | "strings" 20 | 21 | "github.com/gin-contrib/gzip" 22 | "github.com/gin-contrib/sessions" 23 | "github.com/gin-contrib/sessions/cookie" 24 | "github.com/gin-gonic/gin" 25 | ) 26 | 27 | //go:embed * 28 | var content embed.FS 29 | 30 | type Server struct { 31 | httpServer *http.Server 32 | listener net.Listener 33 | ctx context.Context 34 | cancel context.CancelFunc 35 | settingService service.SettingService 36 | } 37 | 38 | func NewServer() *Server { 39 | ctx, cancel := context.WithCancel(context.Background()) 40 | return &Server{ 41 | ctx: ctx, 42 | cancel: cancel, 43 | } 44 | } 45 | 46 | func (s *Server) initRouter() (*gin.Engine, error) { 47 | if config.IsDebug() { 48 | gin.SetMode(gin.DebugMode) 49 | } else { 50 | gin.DefaultWriter = io.Discard 51 | gin.DefaultErrorWriter = io.Discard 52 | gin.SetMode(gin.ReleaseMode) 53 | } 54 | 55 | engine := gin.Default() 56 | 57 | // Load the HTML template 58 | t := template.New("").Funcs(engine.FuncMap) 59 | template, err := t.ParseFS(content, "html/index.html") 60 | if err != nil { 61 | return nil, err 62 | } 63 | engine.SetHTMLTemplate(template) 64 | 65 | base_url, err := s.settingService.GetWebPath() 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | webDomain, err := s.settingService.GetWebDomain() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | if webDomain != "" { 76 | engine.Use(middleware.DomainValidator(webDomain)) 77 | } 78 | 79 | secret, err := s.settingService.GetSecret() 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | engine.Use(gzip.Gzip(gzip.DefaultCompression)) 85 | assetsBasePath := base_url + "assets/" 86 | 87 | store := cookie.NewStore(secret) 88 | engine.Use(sessions.Sessions("s-ui", store)) 89 | 90 | engine.Use(func(c *gin.Context) { 91 | uri := c.Request.RequestURI 92 | if strings.HasPrefix(uri, assetsBasePath) { 93 | c.Header("Cache-Control", "max-age=31536000") 94 | } 95 | }) 96 | 97 | // Serve the assets folder 98 | assetsFS, err := fs.Sub(content, "html/assets") 99 | if err != nil { 100 | panic(err) 101 | } 102 | 103 | engine.StaticFS(assetsBasePath, http.FS(assetsFS)) 104 | 105 | group_apiv2 := engine.Group(base_url + "apiv2") 106 | apiv2 := api.NewAPIv2Handler(group_apiv2) 107 | 108 | group_api := engine.Group(base_url + "api") 109 | api.NewAPIHandler(group_api, apiv2) 110 | 111 | // Serve index.html as the entry point 112 | // Handle all other routes by serving index.html 113 | engine.NoRoute(func(c *gin.Context) { 114 | if c.Request.URL.Path == strings.TrimSuffix(base_url, "/") { 115 | c.Redirect(http.StatusTemporaryRedirect, base_url) 116 | return 117 | } 118 | if !strings.HasPrefix(c.Request.URL.Path, base_url) { 119 | c.String(404, "") 120 | return 121 | } 122 | if c.Request.URL.Path != base_url+"login" && !api.IsLogin(c) { 123 | c.Redirect(http.StatusTemporaryRedirect, base_url+"login") 124 | return 125 | } 126 | if c.Request.URL.Path == base_url+"login" && api.IsLogin(c) { 127 | c.Redirect(http.StatusTemporaryRedirect, base_url) 128 | return 129 | } 130 | c.HTML(http.StatusOK, "index.html", gin.H{"BASE_URL": base_url}) 131 | }) 132 | 133 | return engine, nil 134 | } 135 | 136 | func (s *Server) Start() (err error) { 137 | //This is an anonymous function, no function name 138 | defer func() { 139 | if err != nil { 140 | s.Stop() 141 | } 142 | }() 143 | 144 | engine, err := s.initRouter() 145 | if err != nil { 146 | return err 147 | } 148 | 149 | certFile, err := s.settingService.GetCertFile() 150 | if err != nil { 151 | return err 152 | } 153 | keyFile, err := s.settingService.GetKeyFile() 154 | if err != nil { 155 | return err 156 | } 157 | listen, err := s.settingService.GetListen() 158 | if err != nil { 159 | return err 160 | } 161 | port, err := s.settingService.GetPort() 162 | if err != nil { 163 | return err 164 | } 165 | listenAddr := net.JoinHostPort(listen, strconv.Itoa(port)) 166 | listener, err := net.Listen("tcp", listenAddr) 167 | if err != nil { 168 | return err 169 | } 170 | if certFile != "" || keyFile != "" { 171 | cert, err := tls.LoadX509KeyPair(certFile, keyFile) 172 | if err != nil { 173 | listener.Close() 174 | return err 175 | } 176 | c := &tls.Config{ 177 | Certificates: []tls.Certificate{cert}, 178 | } 179 | listener = network.NewAutoHttpsListener(listener) 180 | listener = tls.NewListener(listener, c) 181 | } 182 | 183 | if certFile != "" || keyFile != "" { 184 | logger.Info("web server run https on", listener.Addr()) 185 | } else { 186 | logger.Info("web server run http on", listener.Addr()) 187 | } 188 | s.listener = listener 189 | 190 | s.httpServer = &http.Server{ 191 | Handler: engine, 192 | } 193 | 194 | go func() { 195 | s.httpServer.Serve(listener) 196 | }() 197 | 198 | return nil 199 | } 200 | 201 | func (s *Server) Stop() error { 202 | s.cancel() 203 | var err error 204 | if s.httpServer != nil { 205 | err = s.httpServer.Shutdown(s.ctx) 206 | if err != nil { 207 | return err 208 | } 209 | } 210 | if s.listener != nil { 211 | err = s.listener.Close() 212 | if err != nil { 213 | return err 214 | } 215 | } 216 | return nil 217 | } 218 | 219 | func (s *Server) GetCtx() context.Context { 220 | return s.ctx 221 | } 222 | --------------------------------------------------------------------------------