Compare commits

...

8 Commits

Author SHA1 Message Date
Alireza Ahmadi 0202a3e055 v1.3.10 2026-02-13 22:30:33 +01:00
Alireza Ahmadi 66ca82c635 Go v1.25.7 2026-02-13 22:00:23 +01:00
Alireza Ahmadi 85d42ee91c improve converting link to json 2026-02-13 21:49:43 +01:00
Alireza Ahmadi bdc25bb3d6 Add contribution guide
#111
2026-02-12 23:43:51 +01:00
Alireza Ahmadi e6689ae2dc [api] convert sub to Json 2026-02-12 23:29:50 +01:00
Alireza Ahmadi 688e0c3e23 add api checkOutbound #761 2026-02-11 21:52:31 +01:00
Alireza Ahmadi d996e7171b fix: core full stop 2026-02-11 19:13:04 +01:00
Alireza Ahmadi 76e91aa9b8 windows pipeline on tag 2026-02-10 00:11:34 +01:00
15 changed files with 491 additions and 97 deletions
+6 -2
View File
@@ -93,7 +93,9 @@ jobs:
- name: Upload to Release
uses: svenstaro/upload-release-action@v2
if: github.event_name == 'release' && github.event.action == 'published'
if: |
(github.event_name == 'release' && github.event.action == 'published') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref }}
@@ -163,7 +165,9 @@ jobs:
- name: Upload ARM64 to Release
uses: svenstaro/upload-release-action@v2
if: github.event_name == 'release' && github.event.action == 'published'
if: |
(github.event_name == 'release' && github.event.action == 'published') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref }}
+276
View File
@@ -0,0 +1,276 @@
# Contributing to S-UI
Thank you for your interest in contributing to S-UI. This document explains how to set up a development environment, follow project conventions, and submit changes. Your contributions help make the **multi-inbound-per-user** approach and the rest of the project better for everyone.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Development Environment Setup](#development-environment-setup)
- [Coding Conventions and Style Guide](#coding-conventions-and-style-guide)
- [Testing](#testing)
- [Features That Need Help](#features-that-need-help)
- [Pull Request Process](#pull-request-process)
- [Adding This Guide in Your Repository](#adding-this-guide-in-your-repository)
- [Reporting Bugs and Requesting Features](#reporting-bugs-and-requesting-features)
---
## Code of Conduct
Please be respectful and constructive when interacting with maintainers and other contributors. This project is for personal learning and communication; use it responsibly and legally.
---
## Development Environment Setup
### Prerequisites
- **Go**: 1.25 or later (see `go.mod` for the exact version).
- **Git**: For cloning and submodules.
- **C compiler**: Required for CGO (e.g. `gcc`, `musl-dev` on Alpine).
- **Node.js** (optional): Only if you plan to work on or rebuild the frontend. The repo can be run with pre-built frontend assets.
### Clone and Submodules
```bash
git clone https://github.com/alireza0/s-ui
cd s-ui
git submodule update --init --recursive
```
The **frontend** lives in a submodule. If you only work on the backend, you can use the existing `web/html` contents or build the frontend once (see below).
### Backend-Only Development (quickest)
1. Build and run with the provided script (builds backend and runs with debug + local DB):
```bash
./runSUI.sh
```
This runs `./build.sh` then `SUI_DB_FOLDER="db" SUI_DEBUG=true ./sui`.
2. Or build manually:
```bash
./build.sh
SUI_DB_FOLDER=db SUI_DEBUG=true ./sui
```
Default panel: **http://localhost:2095/app/** (user: `admin`, password: `admin` — change in production).
### Full Stack (Backend + Frontend)
1. **Frontend** (separate repo in submodule):
```bash
cd frontend
npm install
npm run build
cd ..
```
2. **Replace web assets and build backend**:
```bash
mkdir -p web/html
rm -rf web/html/*
cp -R frontend/dist/* web/html/
go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor" -o sui main.go
```
3. Run:
```bash
SUI_DB_FOLDER=db SUI_DEBUG=true ./sui
```
### Build Tags
The backend is built with these tags for full functionality:
- `with_quic`, `with_grpc`, `with_utls`, `with_acme`, `with_gvisor`
Use the same tags when building locally if you need feature parity with releases.
### Environment Variables (development)
| Variable | Description | Example |
|----------------|--------------------------------|-----------|
| `SUI_DB_FOLDER`| Directory for SQLite DB files | `db` |
| `SUI_DEBUG` | Enable debug mode | `true` |
| `SUI_LOG_LEVEL`| Log level | `debug` |
| `SUI_BIN_FOLDER` | Directory for binaries | `bin` |
### Docker (optional)
```bash
git clone https://github.com/alireza0/s-ui
cd s-ui
git submodule update --init --recursive
docker build -t s-ui .
# or: docker compose up -d
```
---
## Coding Conventions and Style Guide
### General
- Write clear, maintainable code. Prefer small, focused functions and packages.
- Comment non-obvious logic and public APIs.
- Handle errors explicitly; avoid ignoring `err` unless intentional.
### Go Style
- Follow **standard Go style** and **[Effective Go](https://go.dev/doc/effective_go)**.
- Run **gofmt** (or **goimports**) before committing:
```bash
gofmt -w .
# or: goimports -w .
```
- Use **camelCase** for unexported names and **PascalCase** for exported names.
- Keep package names short and lowercase (e.g. `api`, `service`, `util`).
- Group imports: standard library, then third-party, then project imports (as in existing files).
### Project Structure Conventions
- **`api/`**: HTTP handlers and API routing (e.g. `apiHandler.go`, `apiV2Handler.go`).
- **`service/`**: Business logic and panel/core operations.
- **`database/model/`**: GORM models and DB entities.
- **`util/`**: Shared utilities (e.g. link/sub conversion, JSON).
- **`core/`**: sing-box integration and core runtime.
- **`sub/`**: Subscription (link/json) handling.
When adding new features, place code in the appropriate layer (handler → service → model/util) and avoid circular dependencies.
### Naming and Patterns
- Handlers: suffix `Handler` (e.g. `APIHandler`, `APIv2Handler`).
- Services: suffix `Service` or use package name (e.g. `ApiService`, `LinkService`).
- Models: clear struct names with JSON/gorm tags (see `database/model/`).
---
## Testing
### Current State
- The project does not yet have a formal test suite (no `*_test.go` files in the repo).
- CI currently focuses on **builds** (e.g. `release.yml`) rather than automated tests.
### What You Can Do Now
1. **Build verification**: Before submitting a PR, ensure the project builds:
```bash
go build -ldflags "-w -s" -tags "with_quic,with_grpc,with_utls,with_acme,with_gvisor" -o sui main.go
```
2. **Manual testing**: Run with `./runSUI.sh`, test the changed area (panel, API, subscription, etc.).
3. **Future tests**: Contributions that add **unit tests** (e.g. for `util/`, `service/`, or API handlers) or **integration tests** are very welcome. Prefer the standard library `testing` package and table-driven tests where appropriate.
### Running the Linter (optional)
```bash
go vet ./...
# Optional: staticcheck, golangci-lint, etc.
```
---
## Features That Need Help
Community help is especially valuable in these areas. Check the [Issues](https://github.com/alireza0/s-ui/issues) for current tasks and ideas.
### High-Value Areas
- **Multi-inbound per user**: Core differentiator of S-UI; improvements to UX, docs, and robustness are welcome.
- **API (v1 and v2)**: Completeness, consistency, and documentation (see [API Documentation](https://github.com/alireza0/s-ui/wiki/API-Documentation)).
- **Subscription service**: Link conversion, JSON subscription, and info endpoints (`sub/`, `util/`).
- **Testing**: Adding unit and integration tests for critical paths.
- **Documentation**: User docs, API examples, and contribution docs (like this file).
- **Platform support**: macOS is experimental; Windows and Linux improvements are welcome (see `windows/` and `.github/workflows/`).
### How to Find Tasks
- **Good first issue**: Look for issues labeled `good first issue` or `help wanted`.
- **Feature requests**: [Feature request template](.github/ISSUE_TEMPLATE/feature_request.md).
- **Bugs**: [Bug report template](.github/ISSUE_TEMPLATE/bug_report.md).
If you want to work on a larger feature, open an issue first to discuss approach and avoid duplicate work.
---
## Pull Request Process
1. **Fork and branch**
- Fork the repository on GitHub.
- Create a branch from `main`: e.g. `git checkout -b fix/issue-123` or `feature/sub-improvements`.
2. **Make your changes**
- Follow the [Coding Conventions](#coding-conventions-and-style-guide).
- Run `gofmt` and ensure the project builds (see [Testing](#testing)).
- Keep commits focused and messages clear (e.g. "Fix link conversion for VMess", "Add tests for outJson").
3. **Push and open a PR**
- Push your branch and open a Pull Request against `main`.
- Use the PR description to explain:
- What problem or feature the PR addresses.
- What you changed and how to verify it.
- Reference any related issue (e.g. "Fixes #123").
4. **Review and CI**
- Maintainers will review your code. CI (e.g. build workflows) must pass.
- Address feedback by pushing new commits to the same branch.
5. **Merge**
- Once approved and CI is green, a maintainer will merge your PR. Thank you for contributing!
### PR Guidelines
- Prefer **small, reviewable PRs**. Split large features into logical steps.
- Avoid unrelated changes (e.g. formatting-only or refactors in a feature PR).
- Ensure your branch is up to date with `main` before submitting (rebase or merge as the project prefers).
---
## Adding This Guide in Your Repository
If you maintain a fork or your own repository and want the contribution guide to be visible and linked properly:
1. **Keep `CONTRIBUTING.md` in the repository root**
GitHub automatically discovers a file named `CONTRIBUTING.md` (or `CONTRIBUTING`) in the root. When someone opens a new issue or pull request, GitHub can show a link to it. The community profile also uses it for the “Contributing” section.
2. **Link from README**
Add a short line in your main `README.md` so new contributors see it when they land on the repo, for example:
```markdown
**Want to contribute?** See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding conventions, and the pull request process.
```
3. **Optional: GitHub “Contributing” link**
In the repository **Settings → General → Features**, ensure “Issues” (and optionally “Discussions”) are enabled. The link to `CONTRIBUTING.md` appears when users create a new issue or PR; no extra config is needed as long as the file is in the root.
4. **When forking**
If you fork S-UI, `CONTRIBUTING.md` is already in the repo. Update the clone URLs and repo names in this file if you want your forks contribution instructions to point to your own repository.
---
## Reporting Bugs and Requesting Features
- **Bugs**: Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md). Include version, OS, steps to reproduce, and expected vs actual behavior.
- **Features**: Use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md). Describe the use case and, if possible, a proposed approach.
- **Questions**: Use the [question template](.github/ISSUE_TEMPLATE/question-template.md) or discussions if enabled.
---
Thank you for helping S-UI grow. Your contributions make it possible for more users to adopt S-UI in production and benefit from its multi-inbound-per-user design.
+3 -1
View File
@@ -11,6 +11,8 @@
**If you think this project is helpful to you, you may wish to give a**:star2:
**Want to contribute?** See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding conventions, testing, and the pull request process.
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/alireza7)
<a href="https://nowpayments.io/donation/alireza7" target="_blank" rel="noreferrer noopener">
@@ -25,7 +27,7 @@
| Multi-Client/Inbound | :heavy_check_mark: |
| Advanced Traffic Routing Interface | :heavy_check_mark: |
| Client & Traffic & System Status | :heavy_check_mark: |
| Subscription Service (link/json + info)| :heavy_check_mark: |
| Subscription Link (link/json/clash + info)| :heavy_check_mark: |
| Dark/Light Theme | :heavy_check_mark: |
| API Interface | :heavy_check_mark: |
+4
View File
@@ -48,6 +48,8 @@ func (a *APIHandler) postHandler(c *gin.Context) {
a.ApiService.RestartSb(c)
case "linkConvert":
a.ApiService.LinkConvert(c)
case "subConvert":
a.ApiService.SubConvert(c)
case "importdb":
a.ApiService.ImportDb(c)
case "addToken":
@@ -97,6 +99,8 @@ func (a *APIHandler) getHandler(c *gin.Context) {
a.ApiService.GetTokens(c)
case "singbox-config":
a.ApiService.GetSingboxConfig(c)
case "checkOutbound":
a.ApiService.GetCheckOutbound(c)
default:
jsonMsg(c, "failed", common.NewError("unknown action: ", action))
}
+13
View File
@@ -330,6 +330,12 @@ func (a *ApiService) LinkConvert(c *gin.Context) {
jsonObj(c, result, err)
}
func (a *ApiService) SubConvert(c *gin.Context) {
link := c.Request.FormValue("link")
result, err := util.GetExternalSub(link)
jsonObj(c, result, err)
}
func (a *ApiService) ImportDb(c *gin.Context) {
file, _, err := c.Request.FormFile("db")
if err != nil {
@@ -396,3 +402,10 @@ func (a *ApiService) GetSingboxConfig(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename=config_"+time.Now().Format("20060102-150405")+".json")
c.Writer.Write(rawConfig)
}
func (a *ApiService) GetCheckOutbound(c *gin.Context) {
tag := c.Query("tag")
link := c.Query("link")
result := a.ConfigService.CheckOutbound(tag, link)
jsonObj(c, result, nil)
}
+4
View File
@@ -49,6 +49,8 @@ func (a *APIv2Handler) postHandler(c *gin.Context) {
a.ApiService.RestartSb(c)
case "linkConvert":
a.ApiService.LinkConvert(c)
case "subConvert":
a.ApiService.SubConvert(c)
case "importdb":
a.ApiService.ImportDb(c)
default:
@@ -86,6 +88,8 @@ func (a *APIv2Handler) getHandler(c *gin.Context) {
a.ApiService.GetKeypairs(c)
case "getdb":
a.ApiService.GetDb(c)
case "checkOutbound":
a.ApiService.GetCheckOutbound(c)
default:
jsonMsg(c, "failed", common.NewError("unknown action: ", action))
}
+1 -1
View File
@@ -1 +1 @@
1.3.9
1.3.10
-3
View File
@@ -82,12 +82,9 @@ func (c *Core) Start(sbConfig []byte) error {
}
func (c *Core) Stop() error {
if c.isRunning {
c.isRunning = false
return c.instance.Close()
}
return nil
}
func (c *Core) IsRunning() bool {
return c.isRunning
+40
View File
@@ -0,0 +1,40 @@
package core
import (
"context"
"time"
urltest "github.com/sagernet/sing-box/common/urltest"
)
const checkTimeout = 15 * time.Second
type CheckOutboundResult struct {
OK bool
Delay uint16
Error string
}
func CheckOutbound(ctx context.Context, tag string, link string) (result CheckOutboundResult) {
if outbound_manager == nil {
result.Error = "core not running"
return result
}
ob, ok := outbound_manager.Outbound(tag)
if !ok {
result.Error = "outbound not found"
return result
}
ctx, cancel := context.WithTimeout(ctx, checkTimeout)
defer cancel()
delay, err := urltest.URLTest(ctx, link, ob)
if err != nil {
result.Error = err.Error()
return result
}
result.OK = true
result.Delay = delay
return result
}
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/alireza0/s-ui
go 1.25.6
go 1.25.7
require (
github.com/gin-contrib/gzip v1.2.5
+10
View File
@@ -123,6 +123,16 @@ func (s *ConfigService) StopCore() error {
return nil
}
func (s *ConfigService) CheckOutbound(tag string, link string) core.CheckOutboundResult {
if tag == "" {
return core.CheckOutboundResult{Error: "missing query parameter: tag"}
}
if corePtr == nil || !corePtr.IsRunning() {
return core.CheckOutboundResult{Error: "core not running"}
}
return core.CheckOutbound(corePtr.GetCtx(), tag, link)
}
func (s *ConfigService) Save(obj string, act string, data json.RawMessage, initUsers string, loginUser string, hostname string) ([]string, error) {
var err error
var objs []string = []string{obj}
+2 -32
View File
@@ -1,10 +1,7 @@
package sub
import (
"crypto/tls"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/alireza0/s-ui/logger"
@@ -32,7 +29,8 @@ func (s *LinkService) GetLinks(linkJson *json.RawMessage, types string, clientIn
case "external":
result = append(result, link.Uri)
case "sub":
result = append(result, s.getExternalSub(link.Uri)...)
subLinks := util.GetExternalLink(link.Uri)
result = append(result, strings.Split(subLinks, "\n")...)
case "local":
if types == "all" {
result = append(result, s.addClientInfo(link.Uri, clientInfo))
@@ -74,31 +72,3 @@ func (s *LinkService) addClientInfo(uri string, clientInfo string) string {
return uri + clientInfo
}
}
func (s *LinkService) getExternalSub(url string) []string {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
// Make the HTTP request
response, err := client.Get(url)
if err != nil {
logger.Warning("sub: Error making HTTP request:", err)
return nil
}
defer response.Body.Close()
// Read the response body
body, err := io.ReadAll(response.Body)
if err != nil {
logger.Warning("sub: Error reading response body:", err)
return nil
}
// Convert if the content is Base64 encoded
links := util.StrOrBase64Encoded(string(body))
return strings.Split(links, "\n")
}
+31 -54
View File
@@ -200,17 +200,9 @@ func hy(u *url.URL, i int) (*map[string]interface{}, string, error) {
port, _ = strconv.Atoi(portStr)
}
tls := map[string]interface{}{
"enabled": true,
"server_name": query.Get("peer"),
}
alpn := query.Get("alpn")
insecure := query.Get("insecure")
if len(alpn) > 0 {
tls["alpn"] = strings.Split(alpn, ",")
}
if insecure == "1" || insecure == "true" {
tls["insecure"] = true
security := query.Get("security")
if len(security) == 0 {
security = "tls"
}
tag := u.Fragment
@@ -224,7 +216,7 @@ func hy(u *url.URL, i int) (*map[string]interface{}, string, error) {
"server_port": port,
"obfs": query.Get("obfsParam"),
"auth_str": query.Get("auth"),
"tls": tls,
"tls": getTls(security, &query),
}
down, _ := strconv.Atoi(query.Get("downmbps"))
up, _ := strconv.Atoi(query.Get("upmbps"))
@@ -253,17 +245,9 @@ func hy2(u *url.URL, i int) (*map[string]interface{}, string, error) {
port, _ = strconv.Atoi(portStr)
}
tls := map[string]interface{}{
"enabled": true,
"server_name": query.Get("sni"),
}
alpn := query.Get("alpn")
insecure := query.Get("insecure")
if len(alpn) > 0 {
tls["alpn"] = strings.Split(alpn, ",")
}
if insecure == "1" || insecure == "true" {
tls["insecure"] = true
security := query.Get("security")
if len(security) == 0 {
security = "tls"
}
tag := u.Fragment
@@ -276,11 +260,13 @@ func hy2(u *url.URL, i int) (*map[string]interface{}, string, error) {
"server": host,
"server_port": port,
"password": u.User.Username(),
"tls": tls,
"tls": getTls(security, &query),
}
down, _ := strconv.Atoi(query.Get("downmbps"))
up, _ := strconv.Atoi(query.Get("upmbps"))
obfs := query.Get("obfs")
mport := query.Get("mport")
fastopen := query.Get("fastopen")
if down > 0 {
hy2["down_mbps"] = down
}
@@ -293,6 +279,12 @@ func hy2(u *url.URL, i int) (*map[string]interface{}, string, error) {
"password": query.Get("obfs-password"),
}
}
if len(mport) > 0 {
hy2["server_ports"] = strings.Split(mport, ",")
}
if fastopen == "1" || fastopen == "true" {
hy2["fastopen"] = true
}
return &hy2, tag, nil
}
@@ -304,17 +296,9 @@ func anytls(u *url.URL, i int) (*map[string]interface{}, string, error) {
port, _ = strconv.Atoi(portStr)
}
tls := map[string]interface{}{
"enabled": true,
"server_name": query.Get("sni"),
}
alpn := query.Get("alpn")
insecure := query.Get("insecure")
if len(alpn) > 0 {
tls["alpn"] = strings.Split(alpn, ",")
}
if insecure == "1" || insecure == "true" {
tls["insecure"] = true
security := query.Get("security")
if len(security) == 0 {
security = "tls"
}
tag := u.Fragment
@@ -327,7 +311,7 @@ func anytls(u *url.URL, i int) (*map[string]interface{}, string, error) {
"server": host,
"server_port": port,
"password": u.User.Username(),
"tls": tls,
"tls": getTls(security, &query),
}
return &anytls, tag, nil
}
@@ -340,21 +324,9 @@ func tuic(u *url.URL, i int) (*map[string]interface{}, string, error) {
port, _ = strconv.Atoi(portStr)
}
tls := map[string]interface{}{
"enabled": true,
"server_name": query.Get("sni"),
}
alpn := query.Get("alpn")
insecure := query.Get("allow_insecure")
disable_sni := query.Get("disable_sni")
if len(alpn) > 0 {
tls["alpn"] = strings.Split(alpn, ",")
}
if insecure == "1" || insecure == "true" {
tls["insecure"] = true
}
if disable_sni == "1" || disable_sni == "true" {
tls["disable_sni"] = true
security := query.Get("security")
if len(security) == 0 {
security = "tls"
}
tag := u.Fragment
@@ -371,7 +343,7 @@ func tuic(u *url.URL, i int) (*map[string]interface{}, string, error) {
"password": password,
"congestion_control": query.Get("congestion_control"),
"udp_relay_mode": query.Get("udp_relay_mode"),
"tls": tls,
"tls": getTls(security, &query),
}
return &tuic, tag, nil
}
@@ -480,9 +452,11 @@ func getTls(security string, q *url.Values) map[string]interface{} {
tls := map[string]interface{}{}
tls_fp := q.Get("fp")
tls_sni := q.Get("sni")
tls_insecure := q.Get("allowInsecure")
tls_allow_insecure := q.Get("allowInsecure")
tls_insecure := q.Get("insecure")
tls_alpn := q.Get("alpn")
tls_ech := q.Get("ech")
disable_sni := q.Get("disable_sni")
switch security {
case "tls":
tls["enabled"] = true
@@ -500,7 +474,7 @@ func getTls(security string, q *url.Values) map[string]interface{} {
if len(tls_alpn) > 0 {
tls["alpn"] = strings.Split(tls_alpn, ",")
}
if tls_insecure == "1" || tls_insecure == "true" {
if tls_insecure == "1" || tls_insecure == "true" || tls_allow_insecure == "1" || tls_allow_insecure == "true" {
tls["insecure"] = true
}
if len(tls_fp) > 0 {
@@ -517,5 +491,8 @@ func getTls(security string, q *url.Values) map[string]interface{} {
},
}
}
if disable_sni == "1" || disable_sni == "true" {
tls["disable_sni"] = true
}
return tls
}
+97
View File
@@ -0,0 +1,97 @@
package util
import (
"crypto/tls"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/alireza0/s-ui/logger"
"github.com/alireza0/s-ui/util/common"
)
func GetExternalLink(url string) string {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
response, err := client.Get(url)
if err != nil {
logger.Warning("sub: Error making HTTP request:", err)
return ""
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
logger.Warning("sub: Error reading response body:", err)
return ""
}
data := StrOrBase64Encoded(string(body))
return data
}
func GetExternalSub(url string) ([]map[string]interface{}, error) {
var err error
var result []map[string]interface{}
if len(url) == 0 {
return nil, common.NewError("no url")
}
data := GetExternalLink(url)
if len(data) == 0 {
return nil, common.NewError("no result")
}
// if the data is a JSON object
if strings.HasPrefix(data, "{") && strings.HasSuffix(data, "}") {
var jsonData map[string]interface{}
err = json.Unmarshal([]byte(data), &jsonData)
if err != nil {
logger.Warning("sub: Error unmarshalling JSON:", err)
return nil, err
}
outbounds, ok := jsonData["outbounds"].([]any)
if !ok {
logger.Warning("sub: Error getting outbounds:", err)
return nil, err
}
for _, outbound := range outbounds {
outboundMap, ok := outbound.(map[string]interface{})
if ok && len(outboundMap) > 0 {
oType, _ := outboundMap["type"].(string)
switch oType {
case "urltest":
case "direct":
case "selector":
case "block":
continue
default:
result = append(result, outboundMap)
}
}
}
if len(result) == 0 {
return nil, common.NewError("no result")
}
return result, nil
} else {
// if data is a text
links := strings.Split(data, "\n")
for _, link := range links {
linkToJson, _, err := GetOutbound(link, 0)
if err == nil {
result = append(result, *linkToJson)
}
}
}
if len(result) == 0 {
return nil, common.NewError("no result")
}
return result, nil
}