├── .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 | 
5 | 
6 | [](https://goreportcard.com/report/github.com/alireza0/s-ui)
7 | [](https://img.shields.io/github/downloads/alireza0/s-ui/total.svg)
8 | [](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 | [](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 | [](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 |
--------------------------------------------------------------------------------