# 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?"

- Repository: alireza0/s-ui
- GitHub: https://github.com/alireza0/s-ui
- Human wiki: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444
- Complete Markdown: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/llms-full.txt

## 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]()
