Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,15 @@ If you forget the password, run `sudo sbm` and choose `Reset administrator passw
- Copy a single-node URL or display its QR code
- Automatic UUID, Reality key pair, short ID, and Hysteria2 password generation
- Manual traffic reset or monthly reset on days 1–28
- Automatic, prefer IPv4, prefer IPv6, IPv4-only, or IPv6-only proxy egress strategy
- Automatic sing-box validation and rollback when a protocol change fails

### IPv4 / IPv6 egress

Choose an address-family strategy under `Settings → Proxy egress network`. If IPv6 has more accurate geolocation, use **Prefer IPv6**: sing-box prefers IPv6 for destinations with AAAA records and falls back to IPv4 when needed. IPv6-only makes IPv4-only destinations unreachable.

This setting requires sing-box 1.12 or newer and only affects domain destinations received by sing-box. The server cannot switch address family after a client has already resolved a domain to an IP. IPv6 client access also requires a correct AAAA record and matching rules in both the cloud and host firewalls.

## Manage SBM from the terminal

```bash
Expand Down
7 changes: 7 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,15 @@ https://node.example.com:2096/
- 复制单节点链接或显示二维码
- 自动生成 UUID、Reality 密钥、short ID 和 Hysteria2 密码
- 手动重置流量,或设置每月 1~28 日自动重置
- 可选自动、优先 IPv4、优先 IPv6、仅 IPv4 或仅 IPv6 的代理出口策略
- 协议变更前自动校验 sing-box,失败时恢复原配置

### IPv4 / IPv6 出口

在 `设置 → 代理出口网络` 中可选择地址族策略。IPv6 地区归属更准确时建议使用“优先 IPv6”:目标有 AAAA 记录时优先走 IPv6,没有时仍回退 IPv4;“仅 IPv6”会让 IPv4-only 目标无法访问。

该策略要求 sing-box 1.12 或更高版本,并且只影响 sing-box 收到的域名目标。客户端已经把域名解析成 IP 时,服务端无法再切换地址族。客户端通过 IPv6 接入还需要域名有正确的 AAAA 记录,并在云防火墙和主机防火墙中放行对应端口。

## 用 `sbm` 管理服务

```bash
Expand Down
7 changes: 6 additions & 1 deletion internal/core/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,20 @@ func (r Renderer) Render(cfg model.Config) ([]byte, error) {
}
inbounds = append(inbounds, built)
}
direct := map[string]any{"type": "direct", "tag": "direct"}
doc := map[string]any{
"log": map[string]any{"level": "warn", "timestamp": true},
"inbounds": inbounds,
"outbounds": []any{map[string]any{"type": "direct", "tag": "direct"}},
"outbounds": []any{direct},
"route": map[string]any{"rules": []any{}, "final": "direct"},
"experimental": map[string]any{"clash_api": map[string]any{
"external_controller": "127.0.0.1:9090", "secret": cfg.ClashAPISecret,
}},
}
if cfg.OutboundStrategy != "" && cfg.OutboundStrategy != model.OutboundStrategyAuto {
doc["dns"] = map[string]any{"servers": []any{map[string]any{"type": "local", "tag": "local"}}}
direct["domain_resolver"] = map[string]any{"server": "local", "strategy": cfg.OutboundStrategy}
}
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, err
Expand Down
59 changes: 59 additions & 0 deletions internal/core/render_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package core

import (
"encoding/json"
"strings"
"testing"

Expand Down Expand Up @@ -28,3 +29,61 @@ func TestRenderConfig(t *testing.T) {
}
}
}

func TestRenderOutboundStrategy(t *testing.T) {
cfg := validRenderConfig()
cfg.OutboundStrategy = model.OutboundStrategyPreferIPv6
data, err := (Renderer{Registry: protocol.DefaultRegistry()}).Render(cfg)
if err != nil {
t.Fatal(err)
}
var rendered struct {
DNS struct {
Servers []map[string]any `json:"servers"`
} `json:"dns"`
Outbounds []map[string]any `json:"outbounds"`
}
if err := json.Unmarshal(data, &rendered); err != nil {
t.Fatal(err)
}
if len(rendered.DNS.Servers) != 1 || rendered.DNS.Servers[0]["type"] != "local" || rendered.DNS.Servers[0]["tag"] != "local" {
t.Fatalf("unexpected DNS servers: %#v", rendered.DNS.Servers)
}
resolver, ok := rendered.Outbounds[0]["domain_resolver"].(map[string]any)
if !ok || resolver["server"] != "local" || resolver["strategy"] != model.OutboundStrategyPreferIPv6 {
t.Fatalf("unexpected domain resolver: %#v", rendered.Outbounds[0]["domain_resolver"])
}
}

func TestRenderAutomaticOutboundStrategyNeedsNoDNSSection(t *testing.T) {
for _, strategy := range []string{"", model.OutboundStrategyAuto} {
cfg := validRenderConfig()
cfg.OutboundStrategy = strategy
data, err := (Renderer{Registry: protocol.DefaultRegistry()}).Render(cfg)
if err != nil {
t.Fatal(err)
}
var rendered map[string]any
if err := json.Unmarshal(data, &rendered); err != nil {
t.Fatal(err)
}
if _, exists := rendered["dns"]; exists {
t.Fatalf("strategy %q unexpectedly rendered a DNS section", strategy)
}
outbounds := rendered["outbounds"].([]any)
if _, exists := outbounds[0].(map[string]any)["domain_resolver"]; exists {
t.Fatalf("strategy %q unexpectedly rendered a domain resolver", strategy)
}
}
}

func validRenderConfig() model.Config {
secret := strings.Repeat("s", 43)
cfg := model.DefaultConfig()
cfg.Domain = "node.example.com"
cfg.AdminPasswordHash = "hash"
cfg.SessionSecret = secret
cfg.ClashAPISecret = secret
cfg.SubscriptionToken = secret
return cfg
}
10 changes: 9 additions & 1 deletion internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import "time"
const (
ConfigVersion = 1
StateVersion = 1

OutboundStrategyAuto = "auto"
OutboundStrategyPreferIPv4 = "prefer_ipv4"
OutboundStrategyPreferIPv6 = "prefer_ipv6"
OutboundStrategyIPv4Only = "ipv4_only"
OutboundStrategyIPv6Only = "ipv6_only"
)

type Config struct {
Expand All @@ -18,6 +24,7 @@ type Config struct {
SubscriptionToken string `json:"subscriptionToken"`
TotalBytes int64 `json:"totalBytes"`
Reset ResetConfig `json:"reset"`
OutboundStrategy string `json:"outboundStrategy,omitempty"`
Inbounds []Inbound `json:"inbounds"`
}

Expand Down Expand Up @@ -68,7 +75,8 @@ func (s State) Total() int64 { return s.Upload + s.Download }
func DefaultConfig() Config {
return Config{
Version: ConfigVersion, PanelPort: 2096, AdminUsername: "admin",
Reset: ResetConfig{Mode: "none", Day: 1, Timezone: "Local"},
Reset: ResetConfig{Mode: "none", Day: 1, Timezone: "Local"},
OutboundStrategy: OutboundStrategyAuto,
}
}

Expand Down
12 changes: 12 additions & 0 deletions internal/protocol/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func (r *Registry) ValidateConfig(cfg model.Config) error {
if err := ValidateReset(cfg.Reset); err != nil {
return err
}
if err := ValidateOutboundStrategy(cfg.OutboundStrategy); err != nil {
return err
}
type endpoint struct {
id string
port int
Expand Down Expand Up @@ -107,6 +110,15 @@ func (r *Registry) ValidateConfig(cfg model.Config) error {
return nil
}

func ValidateOutboundStrategy(strategy string) error {
switch strategy {
case "", model.OutboundStrategyAuto, model.OutboundStrategyPreferIPv4, model.OutboundStrategyPreferIPv6, model.OutboundStrategyIPv4Only, model.OutboundStrategyIPv6Only:
return nil
default:
return errors.New("出站地址策略无效")
}
}

func ValidateReset(reset model.ResetConfig) error {
if reset.Mode != "none" && reset.Mode != "monthly" {
return errors.New("重置模式只能是 none 或 monthly")
Expand Down
34 changes: 30 additions & 4 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,18 +482,44 @@ func (s *Server) saveConfig(change func(*model.Config)) error {

func (s *Server) getSettings(w http.ResponseWriter, _ *http.Request) {
cfg := s.Config.Get()
writeJSON(w, 200, map[string]any{"domain": cfg.Domain, "panelPort": cfg.PanelPort, "totalBytes": cfg.TotalBytes, "reset": cfg.Reset, "subscriptionURL": subscriptionURL(cfg)})
outboundStrategy := cfg.OutboundStrategy
if outboundStrategy == "" {
outboundStrategy = model.OutboundStrategyAuto
}
writeJSON(w, 200, map[string]any{
"domain": cfg.Domain, "panelPort": cfg.PanelPort, "totalBytes": cfg.TotalBytes, "reset": cfg.Reset,
"outboundStrategy": outboundStrategy, "subscriptionURL": subscriptionURL(cfg),
})
}
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
var input struct {
TotalBytes int64 `json:"totalBytes"`
Reset model.ResetConfig `json:"reset"`
TotalBytes int64 `json:"totalBytes"`
Reset model.ResetConfig `json:"reset"`
OutboundStrategy string `json:"outboundStrategy"`
}
if decodeJSON(r, &input) != nil {
writeError(w, 400, "请求格式无效")
return
}
if err := s.saveConfig(func(cfg *model.Config) { cfg.TotalBytes = input.TotalBytes; cfg.Reset = input.Reset }); err != nil {
if input.OutboundStrategy == "" {
input.OutboundStrategy = model.OutboundStrategyAuto
}
change := func(cfg *model.Config) {
cfg.TotalBytes = input.TotalBytes
cfg.Reset = input.Reset
cfg.OutboundStrategy = input.OutboundStrategy
}
currentStrategy := s.Config.Get().OutboundStrategy
if currentStrategy == "" {
currentStrategy = model.OutboundStrategyAuto
}
var err error
if currentStrategy != input.OutboundStrategy {
err = s.mutate(r.Context(), change)
Comment on lines +517 to +518

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce a newly lowered quota before restarting the core

When one settings request both changes the outbound strategy and lowers totalBytes below the already-recorded usage, routing it through mutate calls Core.Apply using the old QuotaExceeded state. The core is therefore restarted and may serve traffic before the later ReconcileQuota call notices the new limit and stops it; a quota-only update does not have this window. Compute/reconcile the quota against the new settings before allowing this strategy apply to restart sing-box.

Useful? React with 👍 / 👎.

} else {
err = s.saveConfig(change)
Comment on lines +517 to +520

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize the strategy comparison with the settings mutation

When two PUT /api/settings requests overlap, this comparison occurs before either mutate or saveConfig acquires mutationMu. For example, a request restoring auto can read the old auto value while another request is applying prefer_ipv6, then enter saveConfig after that apply and overwrite the business config without reapplying the core configuration. The stored setting and running/generated sing-box configuration then disagree until a later core apply, so select the apply path while holding the same mutation lock.

Useful? React with 👍 / 👎.

}
if err != nil {
writeError(w, 400, err.Error())
return
}
Expand Down
90 changes: 89 additions & 1 deletion internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
Expand All @@ -31,10 +32,22 @@ import (

type successCommander struct{}

func (successCommander) Run(context.Context, string, ...string) ([]byte, error) {
func (successCommander) Run(_ context.Context, name string, args ...string) ([]byte, error) {
if name == "systemctl" && len(args) > 0 && args[0] == "is-active" {
return []byte("active"), nil
}
return []byte("sing-box version 1.12.0"), nil
}

type checkFailureCommander struct{ successCommander }

func (c checkFailureCommander) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
if len(args) > 0 && args[0] == "check" {
return []byte("invalid configuration"), errors.New("exit status 1")
}
return c.successCommander.Run(ctx, name, args...)
}

type fakeReleases struct {
calls int
info releasecheck.Info
Expand Down Expand Up @@ -186,6 +199,81 @@ func TestChangePassword(t *testing.T) {
}
}

func TestSettingsUpdatesOutboundStrategyAndCoreConfig(t *testing.T) {
s, cfg := testServer(t)
response := httptest.NewRecorder()
req := authenticatedRequest(t, s, http.MethodPut, "/api/settings", map[string]any{
"totalBytes": cfg.TotalBytes,
"reset": cfg.Reset,
"outboundStrategy": model.OutboundStrategyPreferIPv6,
})
s.Handler().ServeHTTP(response, req)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if got := s.Config.Get().OutboundStrategy; got != model.OutboundStrategyPreferIPv6 {
t.Fatalf("outbound strategy=%q", got)
}
coreConfig, err := os.ReadFile(s.Core.ConfigPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(coreConfig), `"strategy": "prefer_ipv6"`) || !strings.Contains(string(coreConfig), `"type": "local"`) {
t.Fatalf("core config missing IPv6 preference: %s", coreConfig)
}
}

func TestSettingsReturnsAutomaticStrategyForLegacyConfig(t *testing.T) {
s, cfg := testServer(t)
cfg.OutboundStrategy = ""
if err := s.Config.Replace(cfg); err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, authenticatedRequest(t, s, http.MethodGet, "/api/settings", nil))
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"outboundStrategy":"auto"`) {
t.Fatalf("legacy config was not normalized: %s", response.Body.String())
}
}

func TestSettingsRollsBackOutboundStrategyWhenCoreCheckFails(t *testing.T) {
s, cfg := testServer(t)
s.Core.Commands = checkFailureCommander{}
response := httptest.NewRecorder()
req := authenticatedRequest(t, s, http.MethodPut, "/api/settings", map[string]any{
"totalBytes": cfg.TotalBytes,
"reset": cfg.Reset,
"outboundStrategy": model.OutboundStrategyPreferIPv6,
})
s.Handler().ServeHTTP(response, req)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if got := s.Config.Get().OutboundStrategy; got != model.OutboundStrategyAuto {
t.Fatalf("failed strategy change was not rolled back: %q", got)
}
}

func TestSettingsRejectsUnknownOutboundStrategy(t *testing.T) {
s, cfg := testServer(t)
response := httptest.NewRecorder()
req := authenticatedRequest(t, s, http.MethodPut, "/api/settings", map[string]any{
"totalBytes": cfg.TotalBytes,
"reset": cfg.Reset,
"outboundStrategy": "fastest_magic",
})
s.Handler().ServeHTTP(response, req)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if got := s.Config.Get().OutboundStrategy; got != model.OutboundStrategyAuto {
t.Fatalf("invalid outbound strategy was stored: %q", got)
}
}

func TestSubscriptionContentAndHeaders(t *testing.T) {
s, cfg := testServer(t)
response := httptest.NewRecorder()
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/webembed/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="robots" content="noindex,nofollow">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>SBM</title>
<script type="module" crossorigin src="/assets/index-Bh0S8hUn.js"></script>
<script type="module" crossorigin src="/assets/index-QCFoy2tR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nhtGs45Y.css">
</head>
<body>
Expand Down
Loading