Agent-readable wiki

S-UI Wiki: Your Friendly Guide to the Sing-Box Panel

S-UI is a web-based management panel built on top of SagerNet/Sing-Box, a universal proxy platform. It gives you a browser dashboard to configure proxies, manage clients, track traffic, and generate subscription links — all without touching config files by hand.

Pages

  1. Welcome! So What Is S-UI?Imagine sing-box is a powerful but silent robot that routes internet traffic. S-UI is the friendly remote control you use to tell that robot what to do — through a web browser, not a command line. This opening page asks "why does this exist?" and maps out every other page in the wiki so you always know where to look next.
  2. How Do All the Pieces Fit Together?If S-UI were a pizza restaurant, what would the kitchen, the waiter, and the cash register each do? This page walks through the major Go packages — cmd, core, api, web, sub, database, cronjob — and asks "what question does each one answer?" so you build a mental map before diving into details.
  3. The Robot Inside: How Sing-Box Starts and StopsWhen you press "Start" in the dashboard, what actually happens under the hood? This page follows a single button-click all the way down to the sing-box instance, asking at each step: "who tells whom, and how?" Covers core/main.go, box.go, register*.go, and the outbound-check safety net.
  4. The Memory and the Alarm Clock: Database & Cron JobsWhere does S-UI remember your settings when you restart the server? And who cleans up old traffic stats at 3 a.m.? This page explores the SQLite/GORM database layer and the five scheduled jobs (stats, depletion check, WAL checkpoint, core health check, stats deletion), asking "what problem would happen if this job didn't exist?"
  5. The Waiter: How Your Browser Commands Reach the EngineWhen you click a button in the web panel, a request travels from your browser to a Gin HTTP handler to a service function. This page traces that journey, explaining the v2 token-based API, the admin command handlers, and the domain-validation middleware — asking "what could go wrong at each door, and how is it guarded?"
  6. The Gift Bag: How Subscription Links Are BuiltA subscription link is a URL your VPN app fetches to learn which servers to use. This closing page asks: "how does S-UI turn raw database records into a Clash YAML, a JSON config, or a base64 share-link?" It walks through sub/subService.go, clashService.go, jsonService.go, and linkService.go — then leaves you with the key question to keep exploring: what new formats could you add, and where would you start?

Complete Markdown

# S-UI Wiki: Your Friendly Guide to the Sing-Box Panel

> S-UI is a web-based management panel built on top of SagerNet/Sing-Box, a universal proxy platform. It gives you a browser dashboard to configure proxies, manage clients, track traffic, and generate subscription links — all without touching config files by hand.

## Context Links

- [Agent index](https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/llms.txt)
- [Human interactive wiki](https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444)
- [GitHub repository](https://github.com/alireza0/s-ui)

## Repository Metadata

- Repository: alireza0/s-ui

- Generated: 2026-05-22T01:22:59.804Z
- Updated: 2026-05-22T01:36:27.952Z
- Runtime: Claude Code
- Format: Custom
- Pages: 6

## Page Index

- 01. [Welcome! So What Is S-UI?](https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/01-welcome-so-what-is-s-ui.md) - Imagine sing-box is a powerful but silent robot that routes internet traffic. S-UI is the friendly remote control you use to tell that robot what to do — through a web browser, not a command line. This opening page asks "why does this exist?" and maps out every other page in the wiki so you always know where to look next.
- 02. [How Do All the Pieces Fit Together?](https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/02-how-do-all-the-pieces-fit-together.md) - If S-UI were a pizza restaurant, what would the kitchen, the waiter, and the cash register each do? This page walks through the major Go packages — cmd, core, api, web, sub, database, cronjob — and asks "what question does each one answer?" so you build a mental map before diving into details.
- 03. [The Robot Inside: How Sing-Box Starts and Stops](https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/03-the-robot-inside-how-sing-box-starts-and-stops.md) - When you press "Start" in the dashboard, what actually happens under the hood? This page follows a single button-click all the way down to the sing-box instance, asking at each step: "who tells whom, and how?" Covers core/main.go, box.go, register*.go, and the outbound-check safety net.
- 04. [The Memory and the Alarm Clock: Database & Cron Jobs](https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/04-the-memory-and-the-alarm-clock-database-cron-jobs.md) - Where does S-UI remember your settings when you restart the server? And who cleans up old traffic stats at 3 a.m.? This page explores the SQLite/GORM database layer and the five scheduled jobs (stats, depletion check, WAL checkpoint, core health check, stats deletion), asking "what problem would happen if this job didn't exist?"
- 05. [The Waiter: How Your Browser Commands Reach the Engine](https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/05-the-waiter-how-your-browser-commands-reach-the-engine.md) - When you click a button in the web panel, a request travels from your browser to a Gin HTTP handler to a service function. This page traces that journey, explaining the v2 token-based API, the admin command handlers, and the domain-validation middleware — asking "what could go wrong at each door, and how is it guarded?"
- 06. [The Gift Bag: How Subscription Links Are Built](https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/06-the-gift-bag-how-subscription-links-are-built.md) - A subscription link is a URL your VPN app fetches to learn which servers to use. This closing page asks: "how does S-UI turn raw database records into a Clash YAML, a JSON config, or a base64 share-link?" It walks through sub/subService.go, clashService.go, jsonService.go, and linkService.go — then leaves you with the key question to keep exploring: what new formats could you add, and where would you start?

## Source File Index

- `api/apiService.go`
- `api/apiV2Handler.go`
- `app/app.go`
- `cmd/admin.go`
- `cmd/cmd.go`
- `cmd/setting.go`
- `config/config.go`
- `core/box.go`
- `core/endpoint.go`
- `core/log.go`
- `core/main.go`
- `core/outbound_check.go`
- `core/register.go`
- `core/tracker_conn.go`
- `core/tracker_stats.go`
- `cronjob/checkCoreJob.go`
- `cronjob/cronJob.go`
- `cronjob/delStatsJob.go`
- `cronjob/depleteJob.go`
- `cronjob/statsJob.go`
- `cronjob/WALCheckpointJob.go`
- `database/backup.go`
- `database/db.go`
- `Dockerfile`
- `go.mod`
- `install.sh`
- `middleware/domainValidator.go`
- `README.md`
- `s-ui.service`
- `sub/clashService.go`
- `sub/jsonService.go`
- `sub/linkService.go`
- `sub/sub.go`
- `sub/subHandler.go`
- `sub/subService.go`
- `util/genLink.go`
- `util/outJson.go`
- `util/subInfo.go`
- `web/web.go`

---

## 01. Welcome! So What Is S-UI?

> Imagine sing-box is a powerful but silent robot that routes internet traffic. S-UI is the friendly remote control you use to tell that robot what to do — through a web browser, not a command line. This opening page asks "why does this exist?" and maps out every other page in the wiki so you always know where to look next.

- Page Markdown: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/01-welcome-so-what-is-s-ui.md
- Generated: 2026-05-22T01:21:05.853Z

### Source Files

- `README.md`
- `go.mod`
- `cmd/cmd.go`
- `install.sh`
- `Dockerfile`
- `s-ui.service`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [README.md](README.md)
- [go.mod](go.mod)
- [main.go](main.go)
- [app/app.go](app/app.go)
- [cmd/cmd.go](cmd/cmd.go)
- [core/box.go](core/box.go)
- [install.sh](install.sh)
- [Dockerfile](Dockerfile)
- [s-ui.service](s-ui.service)
</details>

# Welcome! So What Is S-UI?

Imagine you bought a very powerful, very complicated piece of machinery — say, an industrial robot that can sort, route, and move things around in a warehouse. The robot is incredibly capable, but to give it instructions you have to write precise technical commands in a language only engineers understand. That's **sing-box**: a powerful, multi-protocol network proxy engine built by [SagerNet](https://github.com/SagerNet/sing-box) that routes your internet traffic through different paths and protocols — but it speaks in raw config files and command-line flags.

**S-UI is the friendly control panel you put on top of that robot.** It gives you a web browser interface where you can point, click, and configure — without ever touching a config file. It also watches the robot's health, tracks how much data each user is consuming, and can share connection settings with clients via subscription links. This page explains *why* S-UI exists, *how* its pieces fit together, and *where to look* in this wiki when you want to go deeper on any topic.

---

## The "Why" — What Problem Does S-UI Solve?

> *Why can't I just configure sing-box directly?*

You can! But sing-box is configured through a single big JSON file. Every time you want to add a new user, change a port, or swap a protocol, you have to:
1. Open the JSON file by hand.
2. Edit the right nested object in exactly the right syntax.
3. Restart sing-box.
4. Check logs to see if you made a typo.

If you manage more than one or two users, that process gets painful fast. S-UI replaces all of that with a web dashboard. It stores your configuration in a **SQLite database** ([`gorm.io/driver/sqlite`](go.mod#L17)), translates your clicks into a valid sing-box config, and then restarts the engine for you — all while showing you live traffic statistics and client status.

> *What is sing-box doing underneath?*

Sing-box is a universal proxy platform. It can speak many different protocols — VMess, VLESS, Trojan, Shadowsocks, Hysteria2, TUIC, SOCKS, HTTP, and more. S-UI lists these protocols in the README and wraps them all behind the same UI — you pick a protocol from a dropdown, and S-UI writes the correct JSON.

---

## The Architecture at a Glance

```text
┌─────────────────────────────────────────────────────┐
│                  Browser / Client                   │
│  (Web Panel at :2095/app)   (Subscription at :2096) │
└────────────────┬────────────────────┬───────────────┘
                 │  HTTP/HTTPS        │  HTTP/HTTPS
┌────────────────▼────────────────────▼───────────────┐
│                     S-UI Process  (sui binary)       │
│  ┌───────────┐  ┌──────────┐  ┌──────────────────┐  │
│  │ web.Server│  │sub.Server│  │   cronjob.CronJob│  │
│  │ (Gin API) │  │ (Gin API)│  │  (stats, cleanup)│  │
│  └─────┬─────┘  └────┬─────┘  └──────────────────┘  │
│        │             │                               │
│  ┌─────▼─────────────▼────────────────────────────┐ │
│  │         service layer (Go packages)             │ │
│  │  panel · config · inbounds · outbounds          │ │
│  │  clients · stats · tls · sub · warp             │ │
│  └─────────────────────┬──────────────────────────┘ │
│                        │                             │
│  ┌─────────────────────▼──────────────────────────┐ │
│  │         database  (SQLite via GORM)             │ │
│  └─────────────────────┬──────────────────────────┘ │
│                        │                             │
│  ┌─────────────────────▼──────────────────────────┐ │
│  │     core.Core  →  core.Box  (sing-box engine)  │ │
│  │     Manages inbounds / outbounds / routing      │ │
│  └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```

Sources: [app/app.go:18-53](), [core/box.go:38-55]()

---

## The Five Moving Parts

### 1. The `sui` Binary — One Process to Rule Them All

When you run S-UI, a single Go binary called `sui` starts up. Its `main()` function decides what to do based on how you called it:

```go
// main.go:42-49
func main() {
    if len(os.Args) < 2 {
        runApp()    // ← normal startup: web panel + sing-box
        return
    } else {
        cmd.ParseCmd()  // ← maintenance subcommands
    }
}
```

No arguments → the full app starts. With arguments → you get administrative subcommands.

Sources: [main.go:42-49]()

---

### 2. The App — Four Servers Working Together

`app.App.Start()` fires up four concurrent components:

| Component | What It Does | Default Address |
|---|---|---|
| `web.Server` | Serves the admin web panel (Gin HTTP framework) | `:2095/app/` |
| `sub.Server` | Serves client subscription links (JSON, Clash, plain links) | `:2096/sub/` |
| `cronjob.CronJob` | Periodic tasks: collect traffic stats, prune old data, check engine health | — (background) |
| `core.Core` / `core.Box` | Wraps sing-box itself: inbounds, outbounds, routing, DNS | — (configured ports) |

Sources: [app/app.go:56-88]()

---

### 3. The Core — A Direct sing-box Wrapper

The `core` package is where S-UI actually *talks* to sing-box. It does not shell out to a separate process — it embeds sing-box as a Go library (`github.com/sagernet/sing-box v1.13.12`) and manages its lifecycle directly.

The `Box` struct mirrors sing-box's internal architecture:

```go
// core/box.go:38-55
type Box struct {
    logFactory   log.Factory
    network      *route.NetworkManager
    inbound      *inbound.Manager
    outbound     *outbound.Manager
    dnsRouter    *dns.Router
    router       *route.Router
    statsTracker *StatsTracker
    connTracker  *ConnTracker
    ...
}
```

This means S-UI has direct access to live connection stats, traffic counters, and the routing table — no parsing of log files needed.

Sources: [core/box.go:38-55](), [go.mod:14]()

---

### 4. The Database — Where Your Config Lives

S-UI persists everything in a **SQLite** database via GORM. The database models live in `database/model/`:

| Model file | What it stores |
|---|---|
| `inbounds.go` | Inbound listener configs (protocol, port, tag) |
| `outbounds.go` | Outbound route configs |
| `endpoints.go` | Endpoint entries |
| `services.go` | Auxiliary services |
| `model.go` | Shared types: Users, Settings, Traffic stats |

When you save a change in the web panel, the service layer writes to SQLite, regenerates a sing-box config JSON in memory, and calls `core.Box` to reload.

Sources: [app/app.go:37](), [go.mod:17-18]()

---

### 5. The CLI — Admin Escape Hatch

If you get locked out of the web panel or need to automate setup, the CLI subcommands let you work directly without a browser:

| Command | What it does |
|---|---|
| `sui admin --show` | Print the first admin's username and password |
| `sui admin --reset` | Reset admin credentials |
| `sui admin --username X --password Y` | Set new credentials |
| `sui setting --port 8080 --path /panel/` | Change panel port/path |
| `sui setting --subPort 8081 --subPath /sub/` | Change subscription port/path |
| `sui uri` | Print the full panel URL |
| `sui migrate` | Run database migrations after an upgrade |
| `sui -v` | Print S-UI and sing-box versions |

Sources: [cmd/cmd.go:17-111]()

---

## How Installation Works

> *If I wanted to run this today, what actually happens?*

### The one-liner path (Linux/macOS)

```sh
bash <(curl -Ls https://raw.githubusercontent.com/alireza0/s-ui/master/install.sh)
```

The `install.sh` script:
1. Detects your OS (`/etc/os-release`) and CPU architecture.
2. Installs system prerequisites (`wget`, `curl`, `tar`, `tzdata`).
3. Downloads the correct pre-built `sui` binary for your platform.
4. Runs `sui migrate` to initialize the database.
5. Asks you to set a panel port, path, subscription port, and path.
6. Installs two systemd unit files: one for S-UI (`s-ui.service`), one for the sing-box engine.
7. Starts both services.

Sources: [install.sh:1-80]()

### The systemd unit

```ini
# s-ui.service
[Service]
WorkingDirectory=/usr/local/s-ui/
ExecStart=/usr/local/s-ui/sui
Restart=on-failure
RestartSec=10s
```

The service runs from `/usr/local/s-ui/` and auto-restarts on failure.

Sources: [s-ui.service:1-12]()

### The Docker path

The multi-stage `Dockerfile` shows the full build:
1. **Stage 1 (`front-builder`)**: `node:alpine` compiles the Vue.js frontend (`npm run build`).
2. **Stage 2 (`backend-builder`)**: `golang:1.26-alpine` compiles the Go binary with tags like `with_quic`, `with_grpc`, `with_utls`, `with_acme`, `with_gvisor` — enabling the full set of sing-box features.
3. **Stage 3 (final)**: `alpine` image ships the `sui` binary, `libcronet.so`, and the entrypoint.

```sh
docker run -itd \
    -p 2095:2095 -p 2096:2096 -p 443:443 -p 80:80 \
    -v $PWD/db/:/app/db/ \
    -v $PWD/cert/:/root/cert/ \
    alireza7/s-ui:latest
```

Sources: [Dockerfile:1-48]()

---

## What Protocols and Platforms Are Supported?

### Protocols (✓ VERIFIED from README)

| Category | Protocols |
|---|---|
| General | Mixed, SOCKS, HTTP, HTTPS, Direct, Redirect, TProxy |
| V2Ray-based | VLESS, VMess, Trojan, Shadowsocks |
| Other | ShadowTLS, Hysteria, Hysteria2, Naive, TUIC |
| Special | XTLS, WireGuard (via `wgctrl`) |

Sources: [README.md:211-215](), [go.mod:15]()

### Platforms (✓ VERIFIED from README)

| OS | Architectures | Status |
|---|---|---|
| Linux | amd64, arm64, armv7, armv6, armv5, 386, s390x | Fully supported |
| Windows | amd64, 386, arm64 | Fully supported |
| macOS | amd64, arm64 | Experimental |

Sources: [README.md:35-39]()

---

## Key Environment Variables

| Variable | Default | Purpose |
|---|---|---|
| `SUI_LOG_LEVEL` | `"info"` | Log verbosity (`debug`/`info`/`warn`/`error`) |
| `SUI_DEBUG` | `false` | Enable debug mode |
| `SUI_BIN_FOLDER` | `"bin"` | Where sing-box binary is looked for |
| `SUI_DB_FOLDER` | `"db"` | Where the SQLite database file lives |
| `SINGBOX_API` | — | Override the sing-box API endpoint |

Sources: [README.md:226-238]()

---

## Where to Go Next in This Wiki

Think of this page as the lobby. Here is every room and what you'll find there:

| Topic | What you'll learn |
|---|---|
| **Installation & Upgrade** | Detailed walk-through of the `install.sh` script, Docker Compose setup, and Windows installer |
| **Web Panel Tour** | The dashboard layout: inbounds, outbounds, clients, traffic stats, system status |
| **Inbounds & Clients** | How to create a listener, assign users, set data caps and expiry dates |
| **Outbounds & Routing** | Advanced traffic routing: PROXY Protocol, transparent proxy, split routing rules |
| **Subscription Service** | How the `:2096/sub/` server generates Clash configs, JSON configs, and plain links |
| **TLS & HTTPS** | Enabling HTTPS for the panel itself using Certbot or manual certs |
| **CLI Reference** | Every `sui` subcommand and flag, with examples |
| **API Documentation** | The REST API surface for automation and integration |
| **Environment Variables** | Full reference for `SUI_*` vars and `SINGBOX_API` |
| **Database & Backup** | Where data lives, how to back it up, how migrations work |
| **Development Setup** | Building frontend + backend from source, contributing a change |

---

## Summary

S-UI is a Go web application that wraps sing-box — the industry-grade proxy engine — in a browser-accessible control panel. It stores configuration in SQLite, talks to sing-box directly as an embedded library (not a subprocess), serves two HTTP endpoints (admin panel and subscription service), and runs background jobs for traffic accounting. The single `sui` binary is the entire program; the `install.sh` script installs it as a systemd service, and a Docker image is also available. Everything above was verified from the repository source; the entry point is [`main.go:13-49`](main.go) and the top-level orchestration lives in [`app/app.go`](app/app.go).

---

## 02. How Do All the Pieces Fit Together?

> If S-UI were a pizza restaurant, what would the kitchen, the waiter, and the cash register each do? This page walks through the major Go packages — cmd, core, api, web, sub, database, cronjob — and asks "what question does each one answer?" so you build a mental map before diving into details.

- Page Markdown: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/02-how-do-all-the-pieces-fit-together.md
- Generated: 2026-05-22T01:21:47.678Z

### Source Files

- `cmd/cmd.go`
- `cmd/admin.go`
- `app/app.go`
- `web/web.go`
- `middleware/domainValidator.go`
- `config/config.go`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [cmd/cmd.go](cmd/cmd.go)
- [cmd/admin.go](cmd/admin.go)
- [app/app.go](app/app.go)
- [web/web.go](web/web.go)
- [api/apiHandler.go](api/apiHandler.go)
- [sub/sub.go](sub/sub.go)
- [sub/subHandler.go](sub/subHandler.go)
- [core/main.go](core/main.go)
- [database/db.go](database/db.go)
- [cronjob/cronJob.go](cronjob/cronJob.go)
- [cronjob/statsJob.go](cronjob/statsJob.go)
- [config/config.go](config/config.go)
- [middleware/domainValidator.go](middleware/domainValidator.go)
</details>

# How Do All the Pieces Fit Together?

Imagine S-UI is a pizza restaurant. The **kitchen** (sing-box, the `core` package) actually makes the pizzas — it handles real network traffic. The **waiter** (`web` + `api`) takes orders from customers sitting at the table (you, the admin in your browser). The **takeout counter** (`sub`) lets delivery apps (VPN clients on phones and laptops) pick up orders by themselves. The **manager** (`app`) hired everyone and keeps the restaurant running. The **cashier's ledger** (`database`) writes down every order, every customer, every bill. And the **kitchen timer** (`cronjob`) checks every few seconds that the oven is still on.

This page walks through each room so you understand *what question each package answers* before you start reading individual files.

---

## The Manager's Office: `app`

**Question answered: "Who starts and stops everything, and in what order?"**

When S-UI launches, `app.App.Init()` is the very first code that runs with real work to do. Think of it as the restaurant manager arriving in the morning and unlocking every door in the right sequence.

```go
// app/app.go:32-53
func (a *APP) Init() error {
    a.initLog()
    err := database.InitDB(config.GetDBPath())   // open the ledger
    a.SettingService.GetAllSetting()             // read the restaurant's rules
    a.core = core.NewCore()                      // prep the kitchen
    a.cronJob = cronjob.NewCronJob()             // set the kitchen timers
    a.webServer = web.NewServer()                // hire the waiter
    a.subServer = sub.NewServer()                // open the takeout counter
    a.configService = service.NewConfigService(a.core)
    return nil
}
```

`Start()` then fires them up in dependency order: timers first, then the web UI, then the takeout counter, then the kitchen (`configService.StartCore()`). `Stop()` reverses the order gracefully.

Sources: [app/app.go:32-103]()

---

## The Receptionist: `cmd`

**Question answered: "What did the operator type on the command line?"**

Before `app` does anything, `cmd.ParseCmd()` reads the terminal arguments. It is the front door of the restaurant — a small dispatch table that routes you to the right person.

| Subcommand | What it does |
|---|---|
| `admin` | Show, reset, or set the first admin's username/password |
| `setting` | Show or update panel port, path, sub port, sub path |
| `uri` | Print the panel URL |
| `migrate` | Run database migrations from older versions |
| *(none)* | Start the full application |

```go
// cmd/cmd.go:70-111  (simplified)
switch os.Args[1] {
case "admin":   updateAdmin / showAdmin / resetAdmin(...)
case "uri":     getPanelURI()
case "migrate": migration.MigrateDb()
case "setting": updateSetting / showSetting(...)
}
```

Notice that `admin` commands open the database directly — they do not start a web server. They are maintenance tools, not runtime paths.

Sources: [cmd/cmd.go:13-112](), [cmd/admin.go:11-64]()

---

## The Waiter: `web` + `api`

**Question answered: "How does the browser talk to S-UI, and what can it ask for?"**

`web.Server` is one Gin HTTP server. It does two things at once:

1. **Serves the Vue.js single-page app** from an embedded filesystem (`//go:embed *` in `web/web.go:29`). The entire admin UI lives inside the binary — no separate static-file server needed.
2. **Mounts the API routes** under `/api` and `/apiv2` within the same process.

```
Browser request
    │
    ├─► /assets/…         → embedded JS/CSS (1-year cache header)
    ├─► /api/:action      → APIHandler  (session-cookie auth)
    ├─► /apiv2/…          → APIv2Handler (token auth)
    └─► everything else   → index.html  (Vue SPA)
```

`api.APIHandler` speaks a simple action-based protocol. A POST to `/api/save` saves configuration; a GET to `/api/load` fetches it all. There is also a second handler, `APIv2Handler`, which uses bearer tokens instead of session cookies — useful for programmatic access or API clients.

```go
// api/apiHandler.go:34-63
case "login":       a.ApiService.Login(c)
case "save":        a.ApiService.Save(c, loginUser)
case "restartApp":  a.ApiService.RestartApp(c)
case "restartSb":   a.ApiService.RestartSb(c)
case "addToken":    a.ApiService.AddToken(c); a.apiv2.ReloadTokens()
```

If the `webDomain` setting is set, the `middleware.DomainValidator` middleware rejects any request whose `Host` header doesn't match exactly — a basic vhost-style guard.

```go
// middleware/domainValidator.go:11-25
func DomainValidator(domain string) gin.HandlerFunc {
    return func(c *gin.Context) {
        host := c.Request.Host
        if host != domain {
            c.AbortWithStatus(http.StatusForbidden)
            return
        }
        c.Next()
    }
}
```

Sources: [web/web.go:29-135](), [api/apiHandler.go:23-80](), [middleware/domainValidator.go:11-25]()

---

## The Takeout Counter: `sub`

**Question answered: "How do VPN client apps fetch their configuration automatically?"**

`sub.Server` is a *completely separate* HTTP server, listening on its own port (configurable via `subPort`). VPN apps like Clash, sing-box, or any link-based client call this endpoint to get their configuration — they never touch the admin UI port.

```
VPN Client App
    │
    └─► GET /sub/<subid>              → plain link list (base64 encoded)
        GET /sub/<subid>?format=json  → sing-box JSON config
        GET /sub/<subid>?format=clash → Clash YAML config
        HEAD /sub/<subid>             → quota/expiry headers only
```

The handler reads a `subid` from the URL path, looks up the matching client record, and delegates to one of three service implementations depending on the `format` query parameter.

```go
// sub/subHandler.go:27-56
subId := c.Param("subid")
format, isFormat := c.GetQuery("format")
switch format {
case "json":  result, headers, err = s.JsonService.GetJson(subId, format)
case "clash": result, headers, err = s.ClashService.GetClash(subId)
default:      result, headers, err = s.SubService.GetSubs(subId)   // link list
}
```

The response always includes standard subscription headers (`Subscription-Userinfo`, `Profile-Update-Interval`, `Profile-Title`) so client apps know when the subscription expires and how much data has been used.

Just like the web server, `sub.Server` can optionally be locked to a specific domain via `subDomain` using the same `DomainValidator` middleware.

Sources: [sub/sub.go:38-67](), [sub/subHandler.go:17-78]()

---

## The Kitchen: `core`

**Question answered: "Who actually moves network packets?"**

`core.Core` wraps **sing-box** (`github.com/sagernet/sing-box`), the underlying proxy engine. S-UI itself does not move traffic — it tells sing-box what to do and sing-box does the real work.

```go
// core/main.go:28-80
type Core struct {
    isRunning bool
    instance  *Box
}

func (c *Core) Start(sbConfig []byte) error {
    var opt option.Options
    opt.UnmarshalJSONContext(globalCtx, sbConfig)   // parse JSON config
    c.instance, err = NewBox(Options{...})           // create sing-box instance
    c.instance.Start()                               // start proxy engine
    inbound_manager  = service.FromContext[adapter.InboundManager](globalCtx)
    outbound_manager = service.FromContext[adapter.OutboundManager](globalCtx)
    ...
}
```

After start, `core` holds references to sing-box's internal managers (inbounds, outbounds, endpoints, router). The `service` layer uses these to query live traffic stats and connection state without restarting sing-box.

Sources: [core/main.go:28-95]()

---

## The Ledger: `database`

**Question answered: "Where is everything stored, and what are the tables?"**

S-UI uses **SQLite** via GORM, stored at a path derived from the binary's own directory (or `SUI_DB_FOLDER` env var). The `InitDB` function runs GORM `AutoMigrate` on startup to create or update all tables.

```go
// database/db.go:97-110
db.AutoMigrate(
    &model.Setting{}, &model.Tls{},
    &model.Inbound{}, &model.Outbound{},
    &model.Service{}, &model.Endpoint{},
    &model.User{},    &model.Tokens{},
    &model.Stats{},   &model.Client{},
    &model.Changes{},
)
```

The database is opened in WAL mode with a 10-second busy timeout, allowing the web server and cronjobs to read concurrently without blocking each other.

| Table | Stores |
|---|---|
| `settings` | Panel port, path, TLS files, time zone, etc. |
| `inbounds` / `outbounds` / `endpoints` | sing-box protocol config blocks |
| `clients` | VPN users with their sub IDs and quotas |
| `stats` | Per-client traffic snapshots |
| `tokens` | API v2 bearer tokens |
| `changes` | Audit log of config changes |

If no user exists on first run, `initUser()` seeds the default `admin`/`admin` credential.

Sources: [database/db.go:36-119]()

---

## The Kitchen Timers: `cronjob`

**Question answered: "What happens automatically while nobody is clicking anything?"**

`cronjob.CronJob` registers five recurring jobs using `github.com/robfig/cron`:

| Schedule | Job | Purpose |
|---|---|---|
| Every 10 s | `StatsJob` | Pull traffic counters from sing-box, save to DB |
| Every 1 m | `DepleteJob` | Mark clients as expired if quota is exhausted |
| Daily | `DelStatsJob` | Delete stats older than `trafficAge` days |
| Every 5 s | `CheckCoreJob` | Restart sing-box if it crashed |
| Every 10 m | `WALCheckpointJob` | Flush the SQLite WAL file |

```go
// cronjob/cronJob.go:22-33
c.cron.AddJob("@every 10s", NewStatsJob(trafficAge > 0))
c.cron.AddJob("@every 1m",  NewDepleteJob())
c.cron.AddJob("@every 5s",  NewCheckCoreJob())
c.cron.AddJob("@every 10m", NewWALCheckpointJob())
```

`StatsJob` is the most important: every 10 seconds it calls `StatsService.SaveStats()`, which reads live counters from sing-box and writes them to the `stats` table so the UI and subscription headers always show current data.

Sources: [cronjob/cronJob.go:17-43](), [cronjob/statsJob.go:19-25]()

---

## The Settings Sheet: `config`

**Question answered: "Where do environment-level defaults come from?"**

`config` is the simplest package. It reads a handful of environment variables and two embedded text files (`version`, `name`) compiled into the binary at build time.

| Env var | Purpose |
|---|---|
| `SUI_DEBUG` | Enable debug logging and Gin debug mode |
| `SUI_LOG_LEVEL` | `debug`, `info`, `warn`, `error` |
| `SUI_DB_FOLDER` | Override the SQLite database directory |

Everything else (port, TLS, paths) lives in the `settings` database table and is read through `SettingService` at runtime — not at compile time.

Sources: [config/config.go:36-68]()

---

## The Mental Map

```text
┌─────────────────────────────────────────────────────────┐
│  cmd  — command-line dispatch (admin, setting, migrate)  │
└────────────────────┬────────────────────────────────────┘
                     │ starts
┌────────────────────▼────────────────────────────────────┐
│  app  — orchestrator (Init → Start → Stop)               │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌─────────┐ │
│  │   web    │  │   sub    │  │  core    │  │ cronjob │ │
│  │ (admin   │  │ (client  │  │ (sing-   │  │ (timers │ │
│  │  UI +    │  │  sub     │  │  box     │  │  stats  │ │
│  │  api)    │  │  feeds)  │  │  engine) │  │  expiry)│ │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘  └────┬────┘ │
│       └──────────────┴─────────────┴─────────────┘      │
│                          │                               │
│                   ┌──────▼──────┐                        │
│                   │  database   │  (SQLite / GORM)        │
│                   └─────────────┘                        │
└─────────────────────────────────────────────────────────┘
```

---

## Putting It All Together

When you run `s-ui` with no arguments, `cmd` sees no subcommand and hands control to `app`. The manager opens the database, loads settings, and starts every server concurrently: the admin UI on one port, the subscription feed on another, and sing-box proxying traffic underneath. Every 10 seconds the kitchen timer reads live traffic numbers and saves them so the subscription headers stay fresh. When you click "Save" in the browser, the waiter (`api`) writes changes to the database and optionally tells the kitchen (`core`) to reload its configuration. When a phone app refreshes its VPN profile, it hits the takeout counter (`sub`) on the separate port and gets back a link list, JSON, or Clash YAML — whichever format it asked for.

Sources: [app/app.go:56-103]()

---

## 03. The Robot Inside: How Sing-Box Starts and Stops

> When you press "Start" in the dashboard, what actually happens under the hood? This page follows a single button-click all the way down to the sing-box instance, asking at each step: "who tells whom, and how?" Covers core/main.go, box.go, register*.go, and the outbound-check safety net.

- Page Markdown: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/03-the-robot-inside-how-sing-box-starts-and-stops.md
- Generated: 2026-05-22T01:22:59.800Z

### Source Files

- `core/main.go`
- `core/box.go`
- `core/register.go`
- `core/endpoint.go`
- `core/outbound_check.go`
- `core/log.go`
- `core/tracker_conn.go`
- `core/tracker_stats.go`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [core/main.go](core/main.go)
- [core/box.go](core/box.go)
- [core/register.go](core/register.go)
- [core/outbound_check.go](core/outbound_check.go)
- [core/log.go](core/log.go)
- [core/tracker_stats.go](core/tracker_stats.go)
- [core/tracker_conn.go](core/tracker_conn.go)
- [service/config.go](service/config.go)
- [service/server.go](service/server.go)
- [api/apiService.go](api/apiService.go)
- [app/app.go](app/app.go)
- [cronjob/checkCoreJob.go](cronjob/checkCoreJob.go)
</details>

# The Robot Inside: How Sing-Box Starts and Stops

Imagine your s-ui dashboard is like a light switch on the wall. When you flip it on, *something* has to go tell the actual light bulb to glow. This page follows exactly that journey — from the moment you press "Start" in the browser, all the way down to the sing-box proxy engine waking up and listening for traffic. Along the way we ask: who tells whom, and how?

The answer involves five layers of code that hand off responsibility like a relay race baton: the HTTP API handler, the `ConfigService`, the `Core` struct, the `Box` struct, and finally all the individual protocol managers (inbounds, outbounds, DNS, etc.). Understanding this chain explains not just *how* start/stop works, but *why* the design makes the system restartable without restarting the whole web server.

---

## Layer 1 — The Dashboard Presses a Button

**Question: What does the browser actually call?**

When you click "Restart Sing-Box" in the dashboard, your browser sends an HTTP request to the s-ui backend. That request lands in `api/apiService.go`:

```go
// api/apiService.go:322-325
func (a *ApiService) RestartSb(c *gin.Context) {
    err := a.ConfigService.RestartCore()
    jsonMsg(c, "restartSb", err)
}
```

Notice: the API handler does *nothing* with sing-box directly. It just calls `ConfigService.RestartCore()` and reports back whether it worked. The API layer is intentionally thin — it only speaks HTTP, and delegates all logic downward.

Sources: [api/apiService.go:322-325]()

---

## Layer 2 — The ConfigService: Traffic Controller

**Question: Why does a "config" service control start/stop?**

Because starting sing-box *is* a config operation — you have to assemble the full JSON config first, then hand it to the engine. The `ConfigService` in `service/config.go` owns both responsibilities.

```go
// service/config.go:16-23
var (
    corePtr             *core.Core
    startCoreMu         sync.Mutex
    startCoreInProgress bool
    lastStartFailTime   time.Time
    startCooldown       = 15 * time.Second
)
```

These package-level variables are the traffic lights. `startCoreMu` is a mutex that prevents two goroutines from both trying to start sing-box at the same moment (e.g., a user click *and* a cron job firing simultaneously). `startCoreInProgress` is a boolean flag that says "we're already mid-start, ignore this request." `lastStartFailTime` enforces a 15-second cooldown after a failed start attempt so the system doesn't hammer itself with retries.

### StartCore: The Real Work

```go
// service/config.go:89-126
func (s *ConfigService) StartCore() error {
    if corePtr.IsRunning() {
        return nil
    }
    startCoreMu.Lock()
    if startCoreInProgress {
        startCoreMu.Unlock()
        return nil
    }
    if time.Since(lastStartFailTime) < startCooldown {
        logger.Info("start core cooldown ", startCooldown/time.Second, " seconds")
        startCoreMu.Unlock()
        return nil
    }
    startCoreInProgress = true
    startCoreMu.Unlock()
    defer func() {
        startCoreMu.Lock()
        startCoreInProgress = false
        startCoreMu.Unlock()
    }()

    rawConfig, err := s.GetConfig("")  // assembles JSON from DB
    err = corePtr.Start(*rawConfig)    // hands off to Core
    ...
}
```

`GetConfig("")` is important: it reads inbounds, outbounds, services, and endpoints from the **database** and merges them with the global settings JSON. The running engine never reads the database — it only knows the config snapshot it was given at startup.

### StopCore and RestartCore

```go
// service/config.go:128-176
func (s *ConfigService) RestartCore() error {
    err := s.StopCore()       // stop first
    if err != nil { return err }
    return s.StartCore()      // then start fresh
}

func (s *ConfigService) StopCore() error {
    err := corePtr.Stop()
    logger.Info("sing-box stopped")
    return err
}
```

Restart is simply stop-then-start-fresh. There's no "hot reload" of a running engine — each restart creates a brand-new `Box` instance from scratch.

Sources: [service/config.go:16-23](), [service/config.go:89-126](), [service/config.go:128-176]()

---

## Layer 3 — The Core Struct: Bookkeeper

**Question: What does `core.Core` actually track?**

`Core` in `core/main.go` is a thin wrapper. It holds just two pieces of state:

```go
// core/main.go:28-31
type Core struct {
    isRunning bool
    instance  *Box
}
```

`isRunning` is the flag the dashboard uses to show the green/red status indicator. `instance` is the live `Box` (the actual engine). When stopped, `instance` is `nil`.

### Context Setup

```go
// core/main.go:33-40
func NewCore() *Core {
    globalCtx = context.Background()
    globalCtx = sb.Context(globalCtx,
        InboundRegistry(), OutboundRegistry(),
        EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry())
    return &Core{isRunning: false, instance: nil}
}
```

`NewCore()` runs once at application startup and bakes all the **protocol registries** into the Go context. Think of this as telling the engine: "here is the complete menu of protocols you are allowed to speak." It does NOT start sing-box yet.

### Core.Start

```go
// core/main.go:50-81
func (c *Core) Start(sbConfig []byte) error {
    var opt option.Options
    err := opt.UnmarshalJSONContext(globalCtx, sbConfig)  // parse JSON config
    c.instance, err = NewBox(Options{Context: globalCtx, Options: opt})  // build Box
    err = c.instance.Start()  // fire it up
    if err != nil {
        _ = c.instance.Close()
        c.instance = nil
        return err
    }
    // wire global adapter references for hot-path lookups
    inbound_manager  = service.FromContext[adapter.InboundManager](globalCtx)
    outbound_manager = service.FromContext[adapter.OutboundManager](globalCtx)
    ...
    c.isRunning = true
    return nil
}
```

If `instance.Start()` fails, the failed Box is immediately closed and `instance` is set to `nil` — the system goes back to "stopped" state cleanly.

### Core.Stop

```go
// core/main.go:83-91
func (c *Core) Stop() error {
    c.isRunning = false
    if c.instance == nil {
        return nil
    }
    err := c.instance.Close()
    c.instance = nil
    return err
}
```

`isRunning` is set to `false` *before* calling `Close()`. This means any concurrent check like `corePtr.IsRunning()` (e.g., from a stats goroutine) will immediately see the engine as stopped, even while `Close()` is still finishing.

Sources: [core/main.go:28-91]()

---

## Layer 4 — The Box: The Engine Itself

**Question: What is a `Box`, and what happens inside `NewBox`?**

`Box` is s-ui's own version of sing-box's top-level container. It holds every subsystem:

```go
// core/box.go:38-55
type Box struct {
    createdAt    time.Time
    logFactory   log.Factory
    network      *route.NetworkManager
    endpoint     *endpoint.Manager
    inbound      *inbound.Manager
    outbound     *outbound.Manager
    service      *boxService.Manager
    dnsTransport *dns.TransportManager
    dnsRouter    *dns.Router
    connection   *route.ConnectionManager
    router       *route.Router
    internalService []adapter.LifecycleService
    statsTracker *StatsTracker
    connTracker  *ConnTracker
    done         chan struct{}
}
```

`NewBox` is the big constructor. It creates every manager in order, registers them in the context, builds DNS servers, endpoints, inbounds, outbounds, and services one by one. Two s-ui-specific objects are attached at the end:

```go
// core/box.go:329-332
statsTracker := NewStatsTracker()
connTracker  := NewConnTracker()
router.AppendTracker(statsTracker)
router.AppendTracker(connTracker)
```

These trackers are what give s-ui its traffic statistics and live connection list — they get called by the router on every routed connection.

### The Start Sequence (Four Phases)

`Box.start()` is not a single call — it proceeds in four named phases, each calling `adapter.Start(...)` with a different state constant:

```go
// core/box.go:448-478
func (s *Box) start() error {
    err := s.preStart()   // Phase 1+2: Initialize + Start (no inbounds yet)
    // Phase 3: Start inbounds and endpoints (open ports to traffic)
    err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.endpoint, s.service)
    // Phase 4: PostStart — e.g. rule-set downloads, final wiring
    err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, ...)
    // Phase 5: Started — notify everyone the world is running
    err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, ...)
    ...
}
```

And `preStart()` runs the first two phases:

```go
// core/box.go:425-445
func (s *Box) preStart() error {
    // Phase 1: Initialize (allocate resources, no network yet)
    err = adapter.Start(s.logger, adapter.StartStateInitialize,
        s.network, s.dnsTransport, s.dnsRouter, s.connection,
        s.router, s.outbound, s.inbound, s.endpoint, s.service)
    // Phase 2: Start outbounds and DNS (needed before inbounds can route)
    err = adapter.Start(s.logger, adapter.StartStateStart,
        s.outbound, s.dnsTransport, s.dnsRouter, s.network,
        s.connection, s.router)
    ...
}
```

**Why this ordering?** Inbounds (the ports that accept user traffic) start *after* outbounds and the router are ready. If it were reversed, a connection could arrive before there's anywhere to route it.

### The Shutdown Sequence

`Box.Close()` uses a `done` channel as a one-shot guard — calling Close twice is safe:

```go
// core/box.go:480-560
func (s *Box) Close() error {
    select {
    case <-s.done:
        return nil  // already closed
    default:
        close(s.done)
    }
    // Shut down in reverse dependency order:
    // service → endpoint → inbound → outbound → router →
    // connection → dns-router → dns-transport → network
    for _, closeItem := range []struct{ name string; service adapter.Lifecycle }{
        {"service",      s.service},
        {"endpoint",     s.endpoint},
        {"inbound",      s.inbound},
        {"outbound",     s.outbound},
        {"router",       s.router},
        {"connection",   s.connection},
        {"dns-router",   s.dnsRouter},
        {"dns-transport",s.dnsTransport},
        {"network",      s.network},
    } { ... }
    // Reset trackers (clears stats and kills live connections)
    s.statsTracker.Reset()
    s.connTracker.Reset()
}
```

Each close step is wrapped in a `recover()` — a panic in one subsystem can't prevent the others from shutting down cleanly. `statsTracker.Reset()` and `connTracker.Reset()` clear in-memory byte counters and force-close any connections that are still open.

Sources: [core/box.go:38-55](), [core/box.go:329-332](), [core/box.go:425-560]()

---

## Layer 5 — The Registries: The Menu of Protocols

**Question: How does sing-box know about VLESS, Hysteria2, WireGuard, etc.?**

Before any Box is created, `NewCore()` calls the four registry builder functions in `core/register.go`. Each function creates a typed registry and registers every supported protocol into it:

```go
// core/register.go:44-69
func InboundRegistry() *inbound.Registry {
    registry := inbound.NewRegistry()
    tun.RegisterInbound(registry)
    socks.RegisterInbound(registry)
    vmess.RegisterInbound(registry)
    vless.RegisterInbound(registry)
    hysteria2.RegisterInbound(registry)
    // ... and many more
    return registry
}
```

The same pattern applies for `OutboundRegistry()`, `EndpointRegistry()` (WireGuard, Tailscale), `DNSTransportRegistry()`, and `ServiceRegistry()`. These registries are baked into the `globalCtx` context and passed into every `Box` that is created. They act as a static lookup table: "given the type string `'vless'`, which Go constructor function do I call?"

Sources: [core/register.go:44-139]()

---

## The Outbound Check Safety Net

**Question: What's `outbound_check.go` for?**

After sing-box is running, the dashboard lets you test whether a specific outbound can actually reach the internet. That's what `CheckOutbound` does:

```go
// core/outbound_check.go:18-40
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)  // find the named outbound
    if !ok {
        result.Error = "outbound not found"
        return result
    }
    ctx, cancel := context.WithTimeout(ctx, checkTimeout)  // 15 second timeout
    defer cancel()
    delay, err := urltest.URLTest(ctx, link, ob)  // measure latency
    ...
}
```

`outbound_manager` is the package-level variable set by `Core.Start()` after the engine comes up. If the engine isn't running, the check fails immediately with a clear error rather than panicking on a nil pointer.

Sources: [core/outbound_check.go:10-40]()

---

## The Automatic Watchdog

**Question: What if sing-box crashes? Does it restart itself?**

Yes. There is a cron job that periodically calls `StartCore()`:

```go
// cronjob/checkCoreJob.go:15-17
func (s *CheckCoreJob) Run() {
    s.ConfigService.StartCore()
}
```

`StartCore()` is idempotent: if the engine is already running, it exits immediately without doing anything. If the engine has stopped (crashed or was never started), it attempts a restart — subject to the 15-second cooldown guard in `service/config.go`. Additionally, every time a config change is saved via the dashboard, the defer block in `ConfigService.Save()` calls `StartCore()` too:

```go
// service/config.go:196-200
defer func() {
    if err == nil {
        tx.Commit()
        if !corePtr.IsRunning() {
            s.StartCore()
        }
    }
}()
```

Sources: [cronjob/checkCoreJob.go:15-17](), [service/config.go:193-201]()

---

## The Full Journey: A Diagram

```text
Browser "Start" click
        │
        ▼
┌─────────────────────────┐
│  api/apiService.go      │  HTTP handler
│  RestartSb()            │  → calls ConfigService
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  service/config.go      │  Traffic controller
│  StartCore()            │  • mutex guards double-start
│  GetConfig()            │  • assembles JSON from DB
│  → corePtr.Start(json)  │  • enforces 15s cooldown
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  core/main.go           │  Bookkeeper
│  Core.Start()           │  • parses JSON config
│  NewBox(Options{...})   │  • creates Box
│  instance.Start()       │  • sets isRunning=true
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  core/box.go            │  Engine
│  NewBox()               │  • creates all managers
│  Box.start()            │  • attaches StatsTracker+ConnTracker
│  Phase 1: Initialize    │  • Phase 1: allocate resources
│  Phase 2: Start routing │  • Phase 2: start outbounds+DNS
│  Phase 3: Open ports    │  • Phase 3: start inbounds (accept traffic)
│  Phase 4: PostStart     │  • Phase 4: finish wiring
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  core/register.go       │  Protocol menu
│  InboundRegistry()      │  • VLESS, VMess, Hysteria2, ...
│  OutboundRegistry()     │  • registered at NewCore() startup
│  EndpointRegistry()     │  • WireGuard, Tailscale
└─────────────────────────┘
```

---

## Summary

When you press "Start", the browser calls `ApiService.RestartSb` → `ConfigService.StartCore` → assembles a JSON config snapshot from the database → `Core.Start` → `NewBox` (wires up all protocol managers plus s-ui's `StatsTracker` and `ConnTracker`) → `Box.start()` runs four ordered phases, opening inbound ports only after outbounds and the router are ready. Stopping reverses the order, closes all subsystems under panic-safe guards, and zeroes the traffic counters. A cron watchdog calls `StartCore()` periodically, and `StartCore`'s mutex-plus-cooldown design makes all callers safe to call concurrently without ever double-starting the engine.

Sources: [core/main.go:50-91](), [core/box.go:480-560]()

---

## 04. The Memory and the Alarm Clock: Database & Cron Jobs

> Where does S-UI remember your settings when you restart the server? And who cleans up old traffic stats at 3 a.m.? This page explores the SQLite/GORM database layer and the five scheduled jobs (stats, depletion check, WAL checkpoint, core health check, stats deletion), asking "what problem would happen if this job didn't exist?"

- Page Markdown: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/04-the-memory-and-the-alarm-clock-database-cron-jobs.md
- Generated: 2026-05-22T01:22:03.145Z

### Source Files

- `database/db.go`
- `database/backup.go`
- `cronjob/cronJob.go`
- `cronjob/statsJob.go`
- `cronjob/depleteJob.go`
- `cronjob/checkCoreJob.go`
- `cronjob/WALCheckpointJob.go`
- `cronjob/delStatsJob.go`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [database/db.go](database/db.go)
- [database/backup.go](database/backup.go)
- [cronjob/cronJob.go](cronjob/cronJob.go)
- [cronjob/statsJob.go](cronjob/statsJob.go)
- [cronjob/depleteJob.go](cronjob/depleteJob.go)
- [cronjob/checkCoreJob.go](cronjob/checkCoreJob.go)
- [cronjob/WALCheckpointJob.go](cronjob/WALCheckpointJob.go)
- [cronjob/delStatsJob.go](cronjob/delStatsJob.go)
- [service/stats.go](service/stats.go)
- [service/client.go](service/client.go)
- [service/config.go](service/config.go)
</details>

# The Memory and the Alarm Clock: Database & Cron Jobs

Imagine S-UI is a little house. The **database** is the house's memory — it remembers who lives there, what the rules are, and how much electricity everyone has used, even after the lights go out and come back on. The **cron jobs** are a set of alarm clocks in the attic: each one wakes up at a fixed time, does one small job quietly, and goes back to sleep. Neither the house's occupants nor the plumbing need to know the alarm clocks exist — they just keep everything running smoothly behind the scenes.

This page answers two questions: *"Where does S-UI remember your settings when you restart the server?"* and *"Who cleans up old traffic statistics at 3 a.m.?"* We'll look at the SQLite/GORM database layer, and then walk through each of the five scheduled jobs, asking the most important Socratic question for each one: **"What problem would happen if this job didn't exist?"**

---

## Part 1 — The Memory (Database Layer)

### What is the database?

S-UI uses **SQLite** — a single-file database that lives on disk, next to the application binary. It does not need a separate database server. When the process restarts, the file is still there, so all your configuration is recovered automatically.

The database is opened via GORM, Go's ORM library. A single package-level variable, `var db *gorm.DB`, holds the one connection that the entire app shares.

Sources: [database/db.go:18]()

### How does S-UI open and configure the database?

`OpenDB` builds the DSN (Data Source Name) — the URL that tells GORM where the file is and how to behave:

```go
// database/db.go:61
dsn := dbPath + sep + "_busy_timeout=10000&_journal_mode=WAL&_cache_size=-200"
db, err = gorm.Open(sqlite.Open(dsn), c)
```

Three flags are baked in:

| Flag | Value | What it does |
|---|---|---|
| `_busy_timeout` | 10 000 ms | If another process has a lock, wait up to 10 seconds before giving up (no immediate crash) |
| `_journal_mode` | WAL | Write-Ahead Logging — readers never block writers, and writes are faster |
| `_cache_size` | -200 | Cap each connection's page cache at ~200 KiB to limit memory use |

The connection pool is also tuned:

```go
// database/db.go:71-74
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(2)
sqlDB.SetConnMaxLifetime(time.Hour)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
```

Sources: [database/db.go:36-79]()

### What tables does S-UI create?

`InitDB` calls GORM's `AutoMigrate`, which creates or updates tables to match Go struct definitions. On a fresh install it also seeds a default `"direct"` outbound and an `admin/admin` user account.

```go
// database/db.go:97-110
err = db.AutoMigrate(
    &model.Setting{},
    &model.Tls{},
    &model.Inbound{},
    &model.Outbound{},
    &model.Service{},
    &model.Endpoint{},
    &model.User{},
    &model.Tokens{},
    &model.Stats{},
    &model.Client{},
    &model.Changes{},
)
```

| Table | Stores |
|---|---|
| `Setting` | Key-value config (e.g. panel port, language) |
| `Inbound` / `Outbound` | sing-box proxy entry/exit points |
| `Client` | Users who consume the proxy (with volume/expiry limits) |
| `Stats` | Time-series traffic snapshots (up/down bytes per resource) |
| `Changes` | Audit log of config mutations |
| `User` | S-UI admin panel accounts |
| `Tokens` | Auth tokens for the panel API |
| `Tls`, `Service`, `Endpoint` | TLS certs, service nodes, and endpoint definitions |

Sources: [database/db.go:82-119]()

### How does backup and restore work?

`GetDb` in `database/backup.go` copies the live database into a temporary on-disk file (with a timestamp in the name), runs `PRAGMA wal_checkpoint` to flush any pending WAL writes, then reads the file into memory and returns it as bytes — ready to be sent to the browser as a download.

`ImportDB` does the reverse with a three-step safe swap:
1. Validate the upload is a real SQLite file (checks the 16-byte magic header `"SQLite format 3\x00"`).
2. Move the current database to a `.backup` file.
3. Move the upload into place and re-run `InitDB`. If that fails, the `.backup` is restored.

After a successful import, `SendSighup` sends `SIGHUP` to itself (or kills itself on Windows) after a 3-second delay so the process gracefully restarts with the new database.

Sources: [database/backup.go:25-309]()

---

## Part 2 — The Alarm Clocks (Cron Jobs)

### How are cron jobs registered?

`CronJob.Start` creates a `robfig/cron` scheduler (with second-level precision) and registers all jobs in a goroutine — so the server does not block startup waiting for jobs to be scheduled.

```go
// cronjob/cronJob.go:17-37
func (c *CronJob) Start(loc *time.Location, trafficAge int) error {
    c.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
    c.cron.Start()

    go func() {
        c.cron.AddJob("@every 10s",  NewStatsJob(trafficAge > 0))
        c.cron.AddJob("@every 1m",   NewDepleteJob())
        if trafficAge > 0 {
            c.cron.AddJob("@daily",  NewDelStatsJob(trafficAge))
        }
        c.cron.AddJob("@every 5s",   NewCheckCoreJob())
        c.cron.AddJob("@every 10m",  NewWALCheckpointJob())
    }()
    return nil
}
```

Notice that `DelStatsJob` is **only registered if `trafficAge > 0`** — that's the "keep traffic stats for N days" setting. If you never configured it, the cleanup job simply doesn't run.

Sources: [cronjob/cronJob.go:1-43]()

Here is a visual summary of the five alarm clocks:

```text
Every 5 seconds  ┌─────────────────────────────────────────┐
                 │ CheckCoreJob — is sing-box still alive? │
                 └─────────────────────────────────────────┘

Every 10 seconds ┌─────────────────────────────────────────┐
                 │ StatsJob — snapshot traffic counters     │
                 └─────────────────────────────────────────┘

Every 1 minute   ┌─────────────────────────────────────────┐
                 │ DepleteJob — disable over-quota clients  │
                 └─────────────────────────────────────────┘

Every 10 minutes ┌─────────────────────────────────────────┐
                 │ WALCheckpointJob — merge WAL into DB    │
                 └─────────────────────────────────────────┘

@daily           ┌─────────────────────────────────────────┐
(only if config) │ DelStatsJob — delete old traffic rows   │
                 └─────────────────────────────────────────┘
```

---

### Job 1 — StatsJob (every 10 seconds)

**What does it do?**

Asks the running sing-box core for its traffic counters, then writes them into the database. It also refreshes the in-memory "who is online right now" list (inbound/outbound/user names that have uploaded anything in the last tick).

```go
// cronjob/statsJob.go:19-25
func (s *StatsJob) Run() {
    err := s.StatsService.SaveStats(s.enableTraffic)
    ...
}
```

`SaveStats` opens a transaction, updates each `Client` row's `up`/`down` columns, builds the online list, and — only when `enableTraffic` is true — inserts a new `Stats` row for time-series charting.

Sources: [service/stats.go:24-88]()

**What problem would happen if this job didn't exist?**

> The "online users" panel would always be empty. Client bandwidth counters (`up` / `down`) would never increment. The traffic charts in the UI would show nothing. You would have no idea how much data your users are consuming until you restarted sing-box and inspected its raw logs.

---

### Job 2 — DepleteJob (every 1 minute)

**What does it do?**

Checks every enabled `Client` to see if they have exceeded their data quota (`up + down > volume`) or their expiry timestamp has passed. If so, it disables them and logs a `Changes` audit record.

```go
// service/client.go:396
err = tx.Model(model.Client{}).
    Where("enable = true AND ((volume >0 AND up+down > volume) OR (expiry > 0 AND expiry < ?))", dt).
    Scan(&clients).Error
```

After disabling clients, it collects the affected inbound IDs and calls `InboundService.RestartInbounds` so sing-box immediately rejects new connections from those users.

Sources: [service/client.go:367-420](), [cronjob/depleteJob.go:18-30]()

**What problem would happen if this job didn't exist?**

> A client whose 10 GB plan ran out at 2:47 a.m. would keep downloading indefinitely. Expiry dates would be decorative. Traffic quotas would be meaningless. The proxy would keep forwarding traffic for accounts you thought you had cut off.

---

### Job 3 — CheckCoreJob (every 5 seconds)

**What does it do?**

Calls `ConfigService.StartCore()`. If sing-box is already running, the call returns immediately (one early-exit check: `if corePtr.IsRunning() { return nil }`). If sing-box has crashed or was never started, this job re-reads the configuration from the database and starts it — with a mutex and a cooldown timer to prevent rapid-fire restart loops.

```go
// service/config.go:89-126
func (s *ConfigService) StartCore() error {
    if corePtr.IsRunning() {
        return nil
    }
    // mutex + cooldown guard
    ...
    err = corePtr.Start(*rawConfig)
    ...
}
```

Sources: [service/config.go:89-126](), [cronjob/checkCoreJob.go:15-17]()

**What problem would happen if this job didn't exist?**

> If sing-box crashed — due to a bad config, an OOM kill, or a transient OS error — it would stay dead until a human noticed and manually restarted it. With this job, the proxy self-heals within 5 seconds.

---

### Job 4 — WALCheckpointJob (every 10 minutes)

**What does it do?**

SQLite in WAL mode writes new data to a separate `.wal` file first. Over time that file can grow large. A *checkpoint* merges the WAL back into the main database file and resets the WAL to empty, keeping disk usage bounded.

```go
// cronjob/WALCheckpointJob.go:14-19
func (s *WALCheckpointJob) Run() {
    db := database.GetDB()
    if err := db.Exec("PRAGMA wal_checkpoint(FULL)").Error; err != nil {
        logger.Error("Error checkpointing WAL: ", err.Error())
    }
}
```

Sources: [cronjob/WALCheckpointJob.go:1-19]()

**What problem would happen if this job didn't exist?**

> SQLite *does* checkpoint automatically under certain conditions, but with 25 open connections each writing stats every 10 seconds, the WAL can grow to hundreds of megabytes without periodic flushing. Read performance also degrades as SQLite must scan a longer WAL to reconstruct the current state of each page. Disk space would silently balloon.

---

### Job 5 — DelStatsJob (daily, only if `trafficAge > 0`)

**What does it do?**

Deletes all rows from the `Stats` table whose `date_time` column is older than `trafficAge` days.

```go
// service/stats.go:159-163
func (s *StatsService) DelOldStats(days int) error {
    oldTime := time.Now().AddDate(0, 0, -(days)).Unix()
    db := database.GetDB()
    return db.Where("date_time < ?", oldTime).Delete(model.Stats{}).Error
}
```

Because `StatsJob` runs every 10 seconds and (when enabled) inserts a new row each time, a busy server can accumulate hundreds of thousands of rows per day. This daily sweep keeps the table — and therefore the database file — from growing without bound.

Sources: [service/stats.go:159-163](), [cronjob/delStatsJob.go:19-26]()

**What problem would happen if this job didn't exist?**

> The `Stats` table would grow forever. After a few weeks on a busy server you could easily have millions of rows, making query times for the traffic chart slow, the database file huge, and backups impractical.

---

## How Everything Connects

```text
                     ┌─────────────────────────────────────┐
                     │            SQLite file               │
                     │  (WAL mode, _busy_timeout=10000)    │
                     │                                      │
  InitDB ──────────► │  Setting, Client, Stats, Changes,   │
  (at startup)       │  Inbound, Outbound, User, Tokens…   │
                     └───────────────┬─────────────────────┘
                                     │  GetDB()
              ┌──────────────────────┼──────────────────────┐
              │                      │                       │
      StatsJob (10s)       DepleteJob (1m)        WALCheckpointJob (10m)
      ↓ SaveStats()        ↓ DepleteClients()     ↓ PRAGMA wal_checkpoint
      update up/down       disable expired         merge WAL → main file
      insert Stats rows    restart inbounds

      CheckCoreJob (5s)    DelStatsJob (daily)
      ↓ StartCore()        ↓ DelOldStats(N days)
      re-launch sing-box   delete old Stats rows
      if it crashed
```

Sources: [cronjob/cronJob.go:17-37]()

---

## Summary

S-UI stores all its configuration — proxies, clients, traffic history, admin accounts — in a single SQLite file opened with WAL mode and a tuned connection pool ([database/db.go:36-79]()). Five background jobs act as its maintenance crew: `StatsJob` keeps bandwidth counters and online-user lists current every 10 seconds; `DepleteJob` enforces quota and expiry rules every minute; `CheckCoreJob` keeps sing-box self-healing within 5 seconds of a crash; `WALCheckpointJob` prevents the WAL file from ballooning every 10 minutes; and `DelStatsJob` purges old traffic rows daily when retention is configured. Remove any one of them and a different, quiet problem accumulates — zombied quotas, dead proxies, disk bloat, or blank charts — until someone notices at the worst possible moment.

---

## 05. The Waiter: How Your Browser Commands Reach the Engine

> When you click a button in the web panel, a request travels from your browser to a Gin HTTP handler to a service function. This page traces that journey, explaining the v2 token-based API, the admin command handlers, and the domain-validation middleware — asking "what could go wrong at each door, and how is it guarded?"

- Page Markdown: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/05-the-waiter-how-your-browser-commands-reach-the-engine.md
- Generated: 2026-05-22T01:21:44.613Z

### Source Files

- `api/apiV2Handler.go`
- `api/apiService.go`
- `web/web.go`
- `middleware/domainValidator.go`
- `cmd/setting.go`
- `util/outJson.go`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [web/web.go](web/web.go)
- [middleware/domainValidator.go](middleware/domainValidator.go)
- [api/apiHandler.go](api/apiHandler.go)
- [api/apiV2Handler.go](api/apiV2Handler.go)
- [api/apiService.go](api/apiService.go)
- [api/session.go](api/session.go)
- [api/utils.go](api/utils.go)
- [cmd/setting.go](cmd/setting.go)
- [util/outJson.go](util/outJson.go)
</details>

# The Waiter: How Your Browser Commands Reach the Engine

Imagine a restaurant. You sit at a table (your browser). You tell a waiter (the HTTP handler) what you want. The waiter checks your ID at the door (authentication), walks to the kitchen (the service layer), and brings back your food (JSON response). This page traces that exact journey in s-ui — from click to kitchen to response — and asks at each stop: *what could go wrong here, and who is standing guard?*

This matters because s-ui manages a live proxy engine (sing-box). A mishandled request could restart services, leak credentials, or let an unauthenticated outsider issue admin commands. Understanding every door — and who guards it — is how you reason about the system's security posture.

---

## Door 0: The Building Entrance — Domain Validation

Before any request reaches a route handler, the entire Gin engine can be wrapped with one global guard:

```go
// web/web.go:77-79
if webDomain != "" {
    engine.Use(middleware.DomainValidator(webDomain))
}
```

This is optional but meaningful. When the operator sets a `webDomain` in settings, every HTTP request must present a `Host` header that exactly matches that domain.

```go
// middleware/domainValidator.go:12-25
func DomainValidator(domain string) gin.HandlerFunc {
    return func(c *gin.Context) {
        host := c.Request.Host
        if colonIndex := strings.LastIndex(host, ":"); colonIndex != -1 {
            host, _, _ = net.SplitHostPort(c.Request.Host)
        }
        if host != domain {
            c.AbortWithStatus(http.StatusForbidden)
            return
        }
        c.Next()
    }
}
```

**Socratic question:** *What happens if `webDomain` is left empty?*  
The middleware is simply **not installed** — there is no fallback domain check. Any request from any host passes through. This is a conscious operator choice, not a forgotten default.

**What could go wrong here?**  
- DNS rebinding attacks: a malicious page could cause your browser to send requests to the panel using a domain it controls, bypassing the check. Setting `webDomain` blocks this specifically.
- The check strips the port before comparing (`net.SplitHostPort`), so `myserver.com:2053` is treated as `myserver.com`. This is correct behavior but worth knowing.

Sources: [middleware/domainValidator.go:11-25](), [web/web.go:77-79]()

---

## The Two Doors Inside: Two API Routes

Once past the building entrance, s-ui sets up **two separate hallways** — two Gin route groups — each with its own bouncer:

```go
// web/web.go:107-111
group_apiv2 := engine.Group(base_url + "apiv2")
apiv2 := api.NewAPIv2Handler(group_apiv2)

group_api := engine.Group(base_url + "api")
api.NewAPIHandler(group_api, apiv2)
```

Think of `/api` as the "members' lounge" (session-based, for logged-in humans using the web UI) and `/apiv2` as the "service entrance" (token-based, for scripts and automation).

```text
Browser
  │
  ▼
┌─────────────────────────────────────────────┐
│  Global: DomainValidator (if configured)    │
└─────────────────────────────────────────────┘
  │
  ├──▶  /api/*      → APIHandler    (session cookie auth)
  └──▶  /apiv2/*    → APIv2Handler  (Token header auth)
```

Sources: [web/web.go:107-111]()

---

## Door 1: `/api` — The Session Cookie Bouncer

The `/api` group is guarded by a session check. The bouncer runs on every request except `login` and `logout` endpoints (they would deadlock otherwise):

```go
// api/apiHandler.go:23-30
func (a *APIHandler) initRouter(g *gin.RouterGroup) {
    g.Use(func(c *gin.Context) {
        path := c.Request.URL.Path
        if !strings.HasSuffix(path, "login") && !strings.HasSuffix(path, "logout") {
            checkLogin(c)
        }
    })
    g.POST("/:postAction", a.postHandler)
    g.GET("/:getAction", a.getHandler)
}
```

`checkLogin` reads a session cookie stored server-side in an encrypted cookie store:

```go
// api/utils.go:81-92
func checkLogin(c *gin.Context) {
    if !IsLogin(c) {
        if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
            pureJsonMsg(c, false, "Invalid login")
        } else {
            c.Redirect(http.StatusTemporaryRedirect, "/login")
        }
        c.Abort()
    } else {
        c.Next()
    }
}
```

**Socratic question:** *Why are there two different failure responses?*  
Because the UI makes both AJAX calls (JavaScript fetch with `X-Requested-With: XMLHttpRequest`) and normal page loads. An AJAX call gets a JSON error so the frontend can display a message. A plain page load gets a redirect to the login page so the browser navigates correctly.

### How login works

```go
// api/apiService.go:262-283
func (a *ApiService) Login(c *gin.Context) {
    remoteIP := getRemoteIp(c)
    loginUser, err := a.UserService.Login(c.Request.FormValue("user"), c.Request.FormValue("pass"), remoteIP)
    // ...
    sessionMaxAge, _ := a.SettingService.GetSessionMaxAge()
    err = SetLoginUser(c, loginUser, sessionMaxAge)
}
```

```go
// api/session.go:20-34
func SetLoginUser(c *gin.Context, userName string, maxAge int) error {
    options := sessions.Options{Path: "/", Secure: false}
    if maxAge > 0 {
        options.MaxAge = maxAge * 60
    }
    s := sessions.Default(c)
    s.Set(loginUser, userName)
    s.Options(options)
    return s.Save()
}
```

The session cookie is signed with a server-generated secret (retrieved via `s.settingService.GetSecret()` at startup) and stored in the browser. The `Secure` flag is hardcoded `false` here — meaning the cookie will also be sent over plain HTTP, not just HTTPS.

**What could go wrong here?**  
- If the panel is served over plain HTTP (no TLS), the session cookie travels unencrypted and could be sniffed. Setting up TLS via `certFile`/`keyFile` is the operator's responsibility.
- `remoteIP` is read from `X-Forwarded-For` header first. If s-ui is behind a proxy, this is correct. If it is not behind a proxy, a client can spoof this header to fake their IP address for rate-limiting or logging purposes.

Sources: [api/apiHandler.go:23-30](), [api/utils.go:81-92](), [api/session.go:20-34](), [api/apiService.go:262-283]()

---

## Door 2: `/apiv2` — The Token Header Bouncer

The `/apiv2` group uses a different authentication scheme: a static bearer token passed in a custom `Token` header. This is designed for automation — cron jobs, external scripts, dashboards — that cannot maintain a browser cookie.

```go
// api/apiV2Handler.go:31-37
func (a *APIv2Handler) initRouter(g *gin.RouterGroup) {
    g.Use(func(c *gin.Context) {
        a.checkToken(c)
    })
    g.POST("/:postAction", a.postHandler)
    g.GET("/:getAction", a.getHandler)
}
```

The token check:

```go
// api/apiV2Handler.go:112-120
func (a *APIv2Handler) checkToken(c *gin.Context) {
    username := a.findUsername(c)
    if username != "" {
        c.Next()
        return
    }
    jsonMsg(c, "", common.NewError("invalid token"))
    c.Abort()
}
```

Tokens are stored in memory as a slice and compared on every request:

```go
// api/apiV2Handler.go:98-110
func (a *APIv2Handler) findUsername(c *gin.Context) string {
    token := c.Request.Header.Get("Token")
    for index, t := range *a.tokens {
        if t.Expiry > 0 && t.Expiry < time.Now().Unix() {
            (*a.tokens) = append((*a.tokens)[:index], (*a.tokens)[index+1:]...)
            continue
        }
        if t.Token == token {
            return t.Username
        }
    }
    return ""
}
```

Notice that `findUsername` does two things at once: it **evicts expired tokens** during the scan, and it **resolves the username** associated with the token. There is no separate cleanup goroutine — cleanup happens lazily during lookup.

**Socratic question:** *Where do these in-memory tokens come from?*  
They are loaded from the database at startup via `a.ReloadTokens()`, and reloaded any time the session-based API adds or deletes a token:

```go
// api/apiHandler.go:56-60
case "addToken":
    a.ApiService.AddToken(c)
    a.apiv2.ReloadTokens()
case "deleteToken":
    a.ApiService.DeleteToken(c)
    a.apiv2.ReloadTokens()
```

**What could go wrong here?**  
- The token is passed as a plain header string. Without TLS, it is readable by anyone on the network path.
- Expired tokens are removed lazily (during a lookup), not proactively. In theory, a burst of requests with invalid tokens could trigger many slice-mutation operations. In practice the token list is expected to be short.
- There is **no rate limiting** at the Go layer. A brute-force attack against `/apiv2` is limited only by network and OS constraints.

Sources: [api/apiV2Handler.go:31-37](), [api/apiV2Handler.go:98-120](), [api/apiHandler.go:56-60]()

---

## The Kitchen: What Happens After Authentication

Once through the door, the handler dispatches to a named action via a `switch` statement. Both `APIHandler` and `APIv2Handler` delegate to the shared `ApiService` struct, which embeds many service types:

```go
// api/apiService.go:16-29
type ApiService struct {
    service.SettingService
    service.UserService
    service.ConfigService
    service.ClientService
    service.TlsService
    service.InboundService
    service.OutboundService
    service.EndpointService
    service.ServicesService
    service.PanelService
    service.StatsService
    service.ServerService
}
```

This is Go's embedding — `ApiService` inherits methods from all of these services. Think of it as one person who can cook every dish.

### GET vs POST: Reading the Menu vs Placing an Order

| HTTP Method | Route Group | Example Actions |
|---|---|---|
| GET `/api/:action` | Session | `load`, `users`, `settings`, `stats`, `logs`, `getdb` |
| POST `/api/:action` | Session | `login`, `save`, `restartApp`, `restartSb`, `importdb` |
| GET `/apiv2/:action` | Token | Same as GET `/api` (minus `tokens`, `singbox-config`) |
| POST `/apiv2/:action` | Token | `save`, `restartApp`, `restartSb`, `linkConvert`, `subConvert`, `importdb` |

**Socratic question:** *Why can't `/apiv2` `login` or `addToken`?*  
Because those operations are inherently session-management operations — they exist to create or manage the credentials used to authenticate. Token-based callers already have credentials. Mixing the two would create a circular dependency.

### The `save` Action: The Most Powerful Order

The `save` action is the most impactful: it writes configuration to the database and optionally restarts the core.

```go
// api/apiService.go:300-315
func (a *ApiService) Save(c *gin.Context, loginUser string) {
    hostname := getHostname(c)
    obj := c.Request.FormValue("object")
    act := c.Request.FormValue("action")
    data := c.Request.FormValue("data")
    initUsers := c.Request.FormValue("initUsers")
    objs, err := a.ConfigService.Save(obj, act, json.RawMessage(data), initUsers, loginUser, hostname)
    if err != nil {
        jsonMsg(c, "save", err)
        return
    }
    err = a.LoadPartialData(c, objs)
    // ...
}
```

After saving, `LoadPartialData` returns the updated data back to the browser immediately — you don't need a separate read request.

Sources: [api/apiService.go:16-29](), [api/apiService.go:300-315](), [api/apiHandler.go:34-107](), [api/apiV2Handler.go:39-96]()

---

## The Reply: How Every Response Is Shaped

All responses flow through one of two small helpers in `api/utils.go`:

```go
// api/utils.go:13-65
type Msg struct {
    Success bool        `json:"success"`
    Msg     string      `json:"msg"`
    Obj     interface{} `json:"obj"`
}

func jsonMsgObj(c *gin.Context, msg string, obj interface{}, err error) {
    m := Msg{Obj: obj}
    if err == nil {
        m.Success = true
        if msg != "" { m.Msg = msg }
    } else {
        m.Success = false
        m.Msg = msg + ": " + err.Error()
        logger.Warning("failed :", err)
    }
    c.JSON(http.StatusOK, m)
}
```

**Socratic question:** *The HTTP status is always `200 OK` even for errors — is that a problem?*  
It is a design choice. The application-level `success: false` field signals failure. HTTP status codes are used only for the authentication layer (403 Forbidden from domain validation, or a redirect from the login check). This simplifies client-side error handling: always parse the JSON body, check `success`.

One exception: `GetDb` streams raw bytes with `Content-Disposition: attachment`, and `GetSingboxConfig` returns raw JSON directly — both bypass the `Msg` envelope entirely.

Sources: [api/utils.go:13-65](), [api/apiService.go:241-251]()

---

## The Full Journey: A Sequence View

```mermaid
sequenceDiagram
    participant B as Browser / Client
    participant DV as DomainValidator middleware
    participant Auth as Auth middleware<br/>(session or token)
    participant H as Handler switch<br/>(APIHandler / APIv2Handler)
    participant S as ApiService
    participant DB as Database / sing-box

    B->>DV: HTTP request (Host: myserver.com)
    alt webDomain configured and mismatch
        DV-->>B: 403 Forbidden
    else domain ok or unconfigured
        DV->>Auth: c.Next()
    end

    alt /api route (session)
        Auth->>Auth: checkLogin() reads cookie
        alt not logged in
            Auth-->>B: JSON "Invalid login" or redirect
        end
    else /apiv2 route (token)
        Auth->>Auth: checkToken() reads Token header
        alt token missing or expired
            Auth-->>B: JSON "invalid token"
        end
    end

    Auth->>H: c.Next() — authenticated
    H->>H: switch on :action param
    H->>S: ApiService.SaveData / GetStats / etc.
    S->>DB: read or write
    DB-->>S: result or error
    S-->>H: data or error
    H-->>B: JSON {success, msg, obj}
```

Sources: [web/web.go:77-111](), [api/apiHandler.go:23-30](), [api/apiV2Handler.go:31-37](), [api/utils.go:50-65]()

---

## Summary: Every Guard at Every Door

| Layer | File | Guard | What it blocks |
|---|---|---|---|
| Domain validator | `middleware/domainValidator.go` | Host header match | Requests from the wrong domain (DNS rebinding etc.) |
| Session auth | `api/utils.go` `checkLogin` | Cookie presence | Unauthenticated browser users on `/api` |
| Token auth | `api/apiV2Handler.go` `checkToken` | `Token` header match + expiry | Unauthenticated or expired script callers on `/apiv2` |
| Action dispatch | `api/apiHandler.go`, `api/apiV2Handler.go` | `switch` default branch | Unknown action names (returns error, does not panic) |
| Response shape | `api/utils.go` `jsonMsgObj` | Always HTTP 200; `success` field signals app-level outcome | N/A — this is the response contract, not a guard |

The system's security is layered: the domain validator keeps out strangers, the auth middlewares keep out the unauthenticated, and the action dispatchers keep unknown commands from reaching the service layer. What the system does **not** do at the Go layer is rate-limit authentication attempts or use constant-time token comparison — both are considerations for hardened deployments. Sources: [api/apiV2Handler.go:98-120](), [middleware/domainValidator.go:11-25]()

---

## 06. The Gift Bag: How Subscription Links Are Built

> A subscription link is a URL your VPN app fetches to learn which servers to use. This closing page asks: "how does S-UI turn raw database records into a Clash YAML, a JSON config, or a base64 share-link?" It walks through sub/subService.go, clashService.go, jsonService.go, and linkService.go — then leaves you with the key question to keep exploring: what new formats could you add, and where would you start?

- Page Markdown: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/pages/06-the-gift-bag-how-subscription-links-are-built.md
- Generated: 2026-05-22T01:21:41.548Z

### Source Files

- `sub/sub.go`
- `sub/subHandler.go`
- `sub/subService.go`
- `sub/clashService.go`
- `sub/jsonService.go`
- `sub/linkService.go`
- `util/genLink.go`
- `util/subInfo.go`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [sub/sub.go](sub/sub.go)
- [sub/subHandler.go](sub/subHandler.go)
- [sub/subService.go](sub/subService.go)
- [sub/clashService.go](sub/clashService.go)
- [sub/jsonService.go](sub/jsonService.go)
- [sub/linkService.go](sub/linkService.go)
- [util/genLink.go](util/genLink.go)
- [util/subInfo.go](util/subInfo.go)
</details>

# The Gift Bag: How Subscription Links Are Built

Imagine you walk into a store and ask for "the usual." The clerk reaches under the counter, puts your favorite things in a bag, and hands it over. A **subscription link** is exactly that bag — a single URL your VPN app fetches, and out comes a list of every server you're allowed to use, formatted exactly the way your app understands.

This page explains how S-UI fills that bag. Starting from an HTTP request, it traces the path through `subHandler.go`, `subService.go`, `linkService.go`, `clashService.go`, `jsonService.go`, and `util/genLink.go`, arriving at the final gift: a base64 text list, a Clash YAML, or a sing-box JSON config.

---

## Step 1 — Who Answers the Door? (`subHandler.go`)

When your VPN app fetches a subscription URL, it hits a Gin HTTP route registered in `sub/subHandler.go`.

```go
// sub/subHandler.go:22-25
g.GET("/:subid", s.subs)
g.HEAD("/:subid", s.subHeaders)
```

The `subs` function looks at the query string first:

```go
// sub/subHandler.go:31-56
format, isFormat := c.GetQuery("format")
if isFormat {
    switch format {
    case "json":
        result, headers, err = s.JsonService.GetJson(subId, format)
    case "clash":
        result, headers, err = s.ClashService.GetClash(subId)
    }
} else {
    result, headers, err = s.SubService.GetSubs(subId)
}
```

**Socratic question:** Why does the code check `isFormat` first rather than just defaulting to an empty string? Because "no format" is not the same as "an unknown format" — the no-query-string path deliberately goes to the base64 share-link format, which is the most widely supported.

The three routes:

| URL | Format | Service |
|-----|--------|---------|
| `/<subid>` | base64 text | `SubService` |
| `/<subid>?format=json` | sing-box JSON | `JsonService` |
| `/<subid>?format=clash` | Clash YAML | `ClashService` |

Sources: [sub/subHandler.go:27-57]()

---

## Step 2 — Finding the Client Record (`jsonService.go`, `subService.go`)

Every subscription path starts by looking up the client by name from the database.

```go
// sub/jsonService.go:99-116
func (j *JsonService) getData(subId string) (*model.Client, []*model.Inbound, error) {
    db := database.GetDB()
    client := &model.Client{}
    err := db.Model(model.Client{}).Where("enable = true and name = ?", subId).First(client).Error
    ...
    var clientInbounds []uint
    err = json.Unmarshal(client.Inbounds, &clientInbounds)
    ...
    err = db.Model(model.Inbound{}).Preload("Tls").Where("id in ?", clientInbounds).Find(&inbounds).Error
    ...
}
```

**Socratic question:** Why does the query filter on `enable = true`? So that disabling a client in the admin panel instantly stops new subscriptions from including that client's servers — no code changes needed.

The `Client` record carries:
- `Inbounds` — a JSON array of inbound IDs this client can use
- `Links` — a JSON array of pre-built or external links
- `Config` — per-protocol user credentials (uuid, password, etc.)
- `Volume`, `Up`, `Down`, `Expiry` — usage and expiry data for headers

Sources: [sub/jsonService.go:98-116]()

---

## Step 3 — Assembling the Links (Three Paths)

### Path A: Base64 Share-Link (`SubService` + `LinkService`)

`SubService.GetSubs` is the simplest path. It reads `client.Links` directly and delegates to `LinkService.GetLinks`:

```go
// sub/subService.go:34-44
linksArray := s.LinkService.GetLinks(&client.Links, "all", clientInfo)
result := strings.Join(linksArray, "\n")
...
if subEncode {
    result = base64.StdEncoding.EncodeToString([]byte(result))
}
```

`LinkService.GetLinks` processes three link types stored in the `Links` JSON field:

```go
// sub/linkService.go:28-39
for _, link := range links {
    switch link.Type {
    case "external":
        result = append(result, link.Uri)        // paste the URI as-is
    case "sub":
        subLinks := util.GetExternalLink(link.Uri) // fetch a remote sub
        result = append(result, strings.Split(subLinks, "\n")...)
    case "local":
        if types == "all" {
            result = append(result, s.addClientInfo(link.Uri, clientInfo))
        }
    }
}
```

**Socratic question:** Why does `local` only appear when `types == "all"` but not for Clash or JSON? Because Clash and JSON services build outbound configs from raw database records and only pull `external` links for additional servers. The `local` type holds already-formatted share URIs — those are the final gift-wrapped items meant only for the share-link format.

Sources: [sub/linkService.go:20-41](), [sub/subService.go:20-45]()

---

### Path B: sing-box JSON (`JsonService`)

The JSON path constructs a full sing-box client config from scratch.

```go
// sub/jsonService.go:51-96
func (j *JsonService) GetJson(subId string, format string) (*string, []string, error) {
    client, inDatas, err := j.getData(subId)
    outbounds, outTags, err := j.getOutbounds(client.Config, inDatas)
    // ...append external links as additional outbounds
    j.addDefaultOutbounds(outbounds, outTags)
    json.Unmarshal([]byte(defaultJson), &jsonConfig)
    jsonConfig["outbounds"] = outbounds
    j.addOthers(&jsonConfig)
    result, _ := json.MarshalIndent(jsonConfig, "", "  ")
}
```

The `defaultJson` constant provides the inbound skeleton (a `tun` + `mixed` listener pair). Then `getOutbounds` iterates through the client's inbound records, takes the pre-computed `OutJson` from each inbound, and merges in the client's credentials from `client.Config`:

```go
// sub/jsonService.go:118-168 (excerpt)
config, _ := configs[protocol].(map[string]interface{})
for key, value := range config {
    if key == "name" || key == "alterId" || (key == "flow" && inData.TlsId == 0) {
        continue
    }
    outbound[key] = value
}
```

Finally, `addDefaultOutbounds` wraps everything in `selector → urltest → direct` outbound groups, and `addOthers` merges optional settings like `dns`, `rules`, and `experimental` from the admin's extension JSON.

Sources: [sub/jsonService.go:51-96](), [sub/jsonService.go:224-245](), [sub/jsonService.go:247-308]()

---

### Path C: Clash YAML (`ClashService`)

The Clash path reuses `JsonService.getData` and `getOutbounds` to get the same list of sing-box-style outbound maps, then **translates** them into Clash's different field names:

```go
// sub/clashService.go:64-103
func (s *ClashService) GetClash(subId string) (*string, []string, error) {
    client, inDatas, err := s.getData(subId)
    outbounds, outTags, err := s.getOutbounds(client.Config, inDatas)
    // ...
    resultStr, err := s.ConvertToClashMeta(outbounds, basicConfig)
}
```

`ConvertToClashMeta` is where the real translation happens. For each outbound it maps sing-box field names to Clash Meta field names:

```go
// sub/clashService.go:114-136 (excerpt)
proxy["name"] = obMap["tag"]
proxy["type"] = t
proxy["server"] = server
proxy["port"] = obMap["server_port"]   // sing-box: "server_port" → Clash: "port"
```

It handles every supported protocol (vmess, vless, trojan, shadowsocks, hysteria/hysteria2, tuic, socks, http, anytls), transport types (ws, grpc, http/h2, httpupgrade), TLS options including Reality and ECH, and multiplexing (smux). The final step merges the proxies into a base YAML config:

```go
// sub/clashService.go:376-392
output["proxies"] = append(p, proxies...)
output["proxy-groups"] = append(pg, proxyGroups[0], proxyGroups[1])
result, err := yaml.Marshal(output)
```

**Socratic question:** Why does `ConvertToClashMeta` skip outbounds of type `selector`, `urltest`, and `direct`? Because those are S-UI's internal routing constructs; Clash builds its own routing groups from scratch using the `ProxyGroups` constant defined in the same file.

Sources: [sub/clashService.go:114-393]()

---

## Step 4 — Generating the Share-Link URIs (`util/genLink.go`)

When S-UI first registers an inbound, it calls `util.LinkGenerator` to produce the actual share-link URIs that get stored in `client.Links`. This is the factory that knows every URI scheme:

```go
// util/genLink.go:14
var InboundTypeWithLink = []string{"socks", "http", "mixed", "shadowsocks",
    "naive", "hysteria", "hysteria2", "anytls", "tuic", "vless", "trojan", "vmess"}
```

Each protocol has its own function. For example, vmess encodes a JSON object as base64:

```go
// util/genLink.go:482-483
jsonStr, _ := json.Marshal(obj)
uri := fmt.Sprintf("vmess://%s", toBase64(jsonStr))
```

While vless/trojan use query-string parameters:

```go
// util/genLink.go:392-397
uri := fmt.Sprintf("vless://%s@%s:%.0f", uuid, addr["server"].(string), port)
uri = addParams(uri, params, addr["remark"].(string))
```

`addParams` (line 516) uses Go's `url.Parse` to properly encode parameters, then appends the server's remark as the URL fragment (`#server-name`).

Sources: [util/genLink.go:21-102](), [util/genLink.go:516-530]()

---

## Step 5 — The Response Headers (`util/subInfo.go`)

Every subscription response, regardless of format, includes three standard HTTP headers:

```go
// util/subInfo.go:9-15
func GetHeaders(client *model.Client, updateInterval int) []string {
    headers = append(headers, fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d",
        client.Up, client.Down, client.Volume, client.Expiry))
    headers = append(headers, fmt.Sprintf("%d", updateInterval))
    headers = append(headers, client.Name)
    return headers
}
```

These map to:

| Header | Content |
|--------|---------|
| `Subscription-Userinfo` | Traffic used + total + expiry timestamp |
| `Profile-Update-Interval` | How often (in hours) the app should re-fetch |
| `Profile-Title` | The client's name shown in the VPN app |

Sources: [util/subInfo.go:9-15](), [sub/subHandler.go:74-78]()

---

## The Full Picture

```text
HTTP GET /<subid>[?format=...]
        │
        ▼
┌─────────────────────┐
│   SubHandler.subs   │  ← sub/subHandler.go
└──────────┬──────────┘
           │
     ┌─────┴──────────────┐
     │                    │
  no ?format          ?format=
     │                    │
     ▼               ┌────┴────┐
SubService         json      clash
  .GetSubs()         │          │
     │               ▼          ▼
     │          JsonService  ClashService
     │           .GetJson()   .GetClash()
     │               │          │
     │        getData() ─────────┘
     │        getOutbounds()
     │               │
     ▼               ▼
LinkService     util/genLink.go
.GetLinks()     (pre-stored URIs)
     │
     ▼
base64-encode (optional)
return string + headers
```

---

## What Could You Add?

Here is the key question to carry forward: **what new format would you add, and where would you start?**

The routing gate is in `subHandler.go:38-39` — add a new `case "singbox-v2"` (or any other format name) there. The data-fetching plumbing (`getData`, `getOutbounds`) is already shared between JSON and Clash — your new service can embed `JsonService` and call those same methods. The translation layer is entirely self-contained in a single function like `ConvertToClashMeta`. And if your format needs new link URI schemes, `util/genLink.go` is where you'd add a new `case` to `LinkGenerator` and register the type in `InboundTypeWithLink`.

The three existing services show the full range: pure text assembly (`SubService`), full config generation (`JsonService`), and protocol translation (`ClashService`). Any new format fits one of those three shapes.

Sources: [sub/subHandler.go:27-57](), [sub/clashService.go:13-17](), [util/genLink.go:14]()

---