# 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.

- 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

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