Skip to content
Merged
119 changes: 73 additions & 46 deletions CLAUDE.md

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions app/architecture/imports_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func TestModuleDependencies(t *testing.T) {
for _, imp := range f.Imports {
value, _ := strconv.Unquote(imp.Path.Value)
if strings.HasPrefix(relative, "routes/") {
for _, forbidden := range []string{"/repo", "/integrations/", "/platform/", "/database", "/internal/", "xorm.io"} {
for _, forbidden := range []string{"/repo", "/singbox/clashapi", "/platform/", "/database", "/internal/", "xorm.io"} {
if strings.Contains(value, forbidden) {
t.Errorf("HTTP adapter %s imports implementation %s", relative, value)
}
Expand All @@ -55,7 +55,7 @@ func TestModuleDependencies(t *testing.T) {
if !ok {
return true
}
for _, name := range []string{"UpdateOutboundsConfig", "UpdateConfigSection", "UpdateConfigSections", "Exec", "NewSession"} {
for _, name := range forbiddenInHandlers {
if selector.Sel.Name == name {
t.Errorf("HTTP adapter %s performs %s", relative, name)
}
Expand All @@ -69,3 +69,14 @@ func TestModuleDependencies(t *testing.T) {
t.Fatal(err)
}
}

// forbiddenInHandlers are calls an HTTP handler must reach through a module
// service, never directly. The config.Manager entries are the whole-document
// and history operations config.Service now owns; before it existed, the
// config handlers were the one place that bypassed the service layer.
var forbiddenInHandlers = []string{
"UpdateOutboundsConfig", "UpdateConfigSection", "UpdateConfigSections", "Exec", "NewSession",
"SaveRawConfig", "ValidateRawConfig", "SaveDocument", "GetConfigDocument", "GetBackupDocument",
"GetVersionDocument", "ListVersions", "DeleteVersion", "DeleteVersions", "Rollback",
"RollbackToVersion", "CoreCapabilities",
}
136 changes: 136 additions & 0 deletions app/architecture/layering_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package architecture

import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)

const pkgRoot = "github.com/SealinGp/sing-box-easy/app/pkg/"

// layerRule forbids a set of imports to every non-test file in one directory
// (dir) or in a whole subtree (dir + "/...").
type layerRule struct {
dir string
forbidden []string // import paths relative to pkgRoot; a trailing "/..." forbids the subtree
why string
}

// The sing-box tree is layered bottom-up:
//
// singbox/core the binary: version, check, capabilities
// singbox/clashapi the running process: its Clash-compatible API
// singbox/config the config document store (Manager)
// singbox/config/{sections,outbounds} editors, siblings of each other
// singbox/install binary, dashboard and first-config installation
// singbox the process controller, which consumes config
//
// Nothing points upward, and the two editor families never reach into each
// other — subscription depends on outbounds, and must not drag the section
// editors in with it.
var singBoxLayers = []layerRule{
{
dir: "singbox/core/...",
forbidden: []string{"..."},
why: "core is the leaf every layer builds on",
},
{
dir: "singbox/clashapi/...",
forbidden: []string{"..."},
why: "clashapi is the leaf boundary to the running process, as core is to the binary",
},
{
dir: "singbox/config/...",
forbidden: []string{"singbox/clashapi/..."},
why: "editing the config must not depend on a running sing-box",
},
{
dir: "singbox/install/...",
forbidden: []string{"singbox", "singbox/clashapi/..."},
why: "installing sing-box needs its config and binary, never the running process or its controller",
},
{
dir: "singbox/config",
forbidden: []string{"singbox/config/sections/...", "singbox/config/outbounds/...", "subscription/..."},
why: "the document store must not know which editors sit on top of it",
},
{
dir: "singbox/config/...",
forbidden: []string{"singbox"},
why: "config sits below the process controller",
},
{
dir: "singbox/config/sections/...",
forbidden: []string{"singbox/config/outbounds/..."},
why: "the section editors and the outbound editor are siblings",
},
{
dir: "singbox/config/outbounds/...",
forbidden: []string{"singbox/config/sections/...", "subscription/..."},
why: "outbounds is imported BY subscription and must stay independent of the section editors",
},
{
dir: "singbox/config/outbounds/nodegroup/...",
forbidden: []string{"singbox/config/outbounds", "singbox/config/outbounds/rules/..."},
why: "nodegroup is a leaf both outbounds and rules import; an edge back would be a cycle",
},
{
dir: "singbox/config/outbounds/nodetag/...",
forbidden: []string{"singbox/config/outbounds", "singbox/config/outbounds/rules/..."},
why: "nodetag is a leaf both outbounds and rules import; an edge back would be a cycle",
},
}

func TestSingBoxLayering(t *testing.T) {
_, file, _, _ := runtime.Caller(0)
pkgDir := filepath.Join(filepath.Dir(filepath.Dir(file)), "pkg")
err := filepath.WalkDir(pkgDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
relDir, _ := filepath.Rel(pkgDir, filepath.Dir(path))
relDir = filepath.ToSlash(relDir)
f, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, imp := range f.Imports {
value, _ := strconv.Unquote(imp.Path.Value)
if !strings.HasPrefix(value, pkgRoot) {
continue
}
target := strings.TrimPrefix(value, pkgRoot)
for _, rule := range singBoxLayers {
if !matches(rule.dir, relDir) {
continue
}
for _, forbidden := range rule.forbidden {
if matches(forbidden, target) {
t.Errorf("%s imports %s: %s", relDir, target, rule.why)
}
}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}

// matches reports whether path is pattern, or lies under it when the pattern
// ends in "/..."; the bare pattern "..." matches everything.
func matches(pattern, path string) bool {
if pattern == "..." {
return true
}
if base, ok := strings.CutSuffix(pattern, "/..."); ok {
return path == base || strings.HasPrefix(path, base+"/")
}
return path == pattern
}
16 changes: 8 additions & 8 deletions app/bootstrap/auth_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@ import (
"testing"

"github.com/SealinGp/sing-box-easy/app/pkg/appconfig"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox"
"github.com/SealinGp/sing-box-easy/app/pkg/platform/sysinfo"
)

func TestResolveAuthEnabled(t *testing.T) {
tests := []struct {
name string
mode string
systemType service.SystemType
systemType sysinfo.SystemType
want bool
}{
{"auto on debian requires login", appconfig.AuthAuto, service.SystemDebian, true},
{"auto on unknown requires login", appconfig.AuthAuto, service.SystemUnknown, true},
{"auto on openwrt skips login", appconfig.AuthAuto, service.SystemOpenWRT, false},
{"enabled wins on openwrt", appconfig.AuthEnabled, service.SystemOpenWRT, true},
{"disabled wins on debian", appconfig.AuthDisabled, service.SystemDebian, false},
{"unexpected mode fails safe to enabled", "banana", service.SystemOpenWRT, true},
{"auto on debian requires login", appconfig.AuthAuto, sysinfo.SystemDebian, true},
{"auto on unknown requires login", appconfig.AuthAuto, sysinfo.SystemUnknown, true},
{"auto on openwrt skips login", appconfig.AuthAuto, sysinfo.SystemOpenWRT, false},
{"enabled wins on openwrt", appconfig.AuthEnabled, sysinfo.SystemOpenWRT, true},
{"disabled wins on debian", appconfig.AuthDisabled, sysinfo.SystemDebian, false},
{"unexpected mode fails safe to enabled", "banana", sysinfo.SystemOpenWRT, true},
}

for _, tt := range tests {
Expand Down
74 changes: 39 additions & 35 deletions app/bootstrap/modules.go
Original file line number Diff line number Diff line change
@@ -1,58 +1,61 @@
package bootstrap

import (
"github.com/SealinGp/sing-box-easy/app/pkg/configuration"
"strings"

"github.com/SealinGp/sing-box-easy/app/pkg/database"
"github.com/SealinGp/sing-box-easy/app/pkg/diagnostics"
"github.com/SealinGp/sing-box-easy/app/pkg/outbounds"
"github.com/SealinGp/sing-box-easy/app/pkg/platform/sysinfo"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox/config/outbounds"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox/config/sections"
"github.com/SealinGp/sing-box-easy/app/pkg/subscription/repo"
"github.com/SealinGp/sing-box-easy/app/pkg/system"
"github.com/SealinGp/sing-box-easy/app/pkg/traffic"
"strings"

"github.com/SealinGp/sing-box-easy/app/pkg/appconfig"
"github.com/SealinGp/sing-box-easy/app/pkg/appupdate"
"github.com/SealinGp/sing-box-easy/app/pkg/config"
"github.com/SealinGp/sing-box-easy/app/pkg/config/history"
"github.com/SealinGp/sing-box-easy/app/pkg/githubauth"
"github.com/SealinGp/sing-box-easy/app/pkg/identity"
"github.com/SealinGp/sing-box-easy/app/pkg/installation"
"github.com/SealinGp/sing-box-easy/app/pkg/installation/state"
"github.com/SealinGp/sing-box-easy/app/pkg/logger"
"github.com/SealinGp/sing-box-easy/app/pkg/outbounds/rules"
"github.com/SealinGp/sing-box-easy/app/pkg/settings"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox/config"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox/config/history"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox/config/outbounds/rules"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox/install"
"github.com/SealinGp/sing-box-easy/app/pkg/singbox/install/state"
"github.com/SealinGp/sing-box-easy/app/pkg/subscription"
)

// Modules contains application-owned dependencies and background lifecycles.
type Modules struct {
System *system.Service
Installation *installer.Service
TrafficService *trafficflow.Service
Installation *install.Service
TrafficService *traffic.Service
SettingsService *settings.Service
Diagnostics *diagnostics.Service
Outbounds *outbounds.Service
Configuration *configuration.Service
Configuration *sections.Service
ConfigService *config.Service
ConfigManager *config.Manager
ServiceController *service.Controller
ServiceController *singbox.Controller
SubscriptionManager *subscription.Service
Installer *installer.Manager
DashboardManager *installer.DashboardManager
InitStateManager initstate.InitStateManager
VersionStore *configversion.StoreXORM
VersionCleaner *configversion.Cleaner
Installer *install.Manager
DashboardManager *install.DashboardManager
InitStateManager state.InitStateManager
VersionStore *history.StoreXORM
VersionCleaner *history.Cleaner
SettingsManager *settings.ManagerXORM
NodeRulesManager *noderules.ManagerXORM
UserManager user.UserManager
NodeRulesManager *rules.ManagerXORM
UserManager identity.UserManager
Updater *appupdate.Updater
GithubAuth *githubauth.Manager
// authEnabled is the resolved login requirement (server.auth × platform).
// false means every request runs as an administrator.
AuthEnabled bool
// systemType is the detected distribution family, probed once at startup.
// It drives both the auth default and the frontend's navigation layout.
SystemType service.SystemType
SystemType sysinfo.SystemType
}

// New constructs dependencies with one explicitly shared database engine.
Expand All @@ -68,23 +71,23 @@ func New(
return nil, err
}
configManager := config.NewManager(configPath, singBoxPath, "") // Use default template path
serviceController := service.NewController(configManager, singBoxPath)
serviceController := singbox.NewController(configManager, singBoxPath)

initStateManager := initstate.NewManagerXORM(e)
initStateManager := state.NewManagerXORM(e)

// Config version history (DB-backed) + application settings.
versionStore := configversion.NewStoreXORM(e)
versionCleaner := configversion.NewCleaner(versionStore, configversion.DefaultMaxAge)
versionStore := history.NewStoreXORM(e)
versionCleaner := history.NewCleaner(versionStore, history.DefaultMaxAge)
settingsManager := settings.NewManagerXORM(e)
configManager.SetVersionStore(versionStore)

// Pass initStateManager and configManager to installer
installerManager := installer.NewManager(initStateManager, configManager)
dashboardManager := installer.NewDashboardManager(initStateManager, configManager)
installerManager := install.NewManager(initStateManager, configManager)
dashboardManager := install.NewDashboardManager(initStateManager, configManager)

// Outbound Node Rules manager (Filters + Groups) — drives auto-grouping of
// subscription nodes.
nodeRulesManager := noderules.NewManagerXORM(e)
nodeRulesManager := rules.NewManagerXORM(e)

subscriptionManager := subscription.NewService(configManager, repo.NewStore(e), nodeRulesManager, settingsManager, serviceController)
if err := subscriptionManager.Init(); err != nil {
Expand All @@ -95,7 +98,7 @@ func New(
}

// Initialize user manager
userManager := user.NewManagerXORM(e, adminUser, adminPass)
userManager := identity.NewManagerXORM(e, adminUser, adminPass)

// Self-update manager (GitHub releases -> binary + frontend swap + restart).
// The token is resolved per request from settings, so signing in through
Expand All @@ -120,7 +123,7 @@ func New(
settingsManager,
)

systemType := service.DetectSystemType()
systemType := sysinfo.DetectSystemType()
authEnabled := resolveAuthEnabled(authMode, systemType)
if !authEnabled {
logger.Warn("==================================================================")
Expand All @@ -132,12 +135,13 @@ func New(

return &Modules{
System: system.New(configManager.GetConfigPath(), database.Path(), serviceController),
Installation: installer.NewService(installerManager, dashboardManager, configManager, initStateManager),
TrafficService: trafficflow.NewService(configManager),
Installation: install.NewService(installerManager, dashboardManager, configManager, initStateManager),
TrafficService: traffic.NewService(configManager),
SettingsService: settings.NewService(settingsManager, configManager, githubAuth.Configured),
Diagnostics: diagnostics.New(configManager, serviceController),
Outbounds: outbounds.New(configManager, nodeRulesManager),
Configuration: configuration.New(configManager),
Configuration: sections.New(configManager),
ConfigService: config.NewService(configManager),
ConfigManager: configManager,
ServiceController: serviceController,
SubscriptionManager: subscriptionManager,
Expand Down Expand Up @@ -196,7 +200,7 @@ func (h *Modules) Start() error {
if err := h.SubscriptionManager.StartBackground("*/5 * * * *"); err != nil {
return err
}
if err := h.VersionCleaner.Start(configversion.DefaultCleanupCron); err != nil {
if err := h.VersionCleaner.Start(history.DefaultCleanupCron); err != nil {
h.SubscriptionManager.StopBackground()
return err
}
Expand All @@ -205,12 +209,12 @@ func (h *Modules) Start() error {

// Close drains workers before the caller closes the database.
func (h *Modules) Close() { h.SubscriptionManager.StopBackground(); h.VersionCleaner.Stop() }
func resolveAuthEnabled(mode string, systemType service.SystemType) bool {
func resolveAuthEnabled(mode string, systemType sysinfo.SystemType) bool {
switch mode {
case appconfig.AuthDisabled:
return false
case appconfig.AuthAuto:
return systemType != service.SystemOpenWRT
return systemType != sysinfo.SystemOpenWRT
default: // appconfig.AuthEnabled and anything unexpected
return true
}
Expand Down
Loading
Loading