From c3046d51c09d3eded15bb93e58c6f06280792063 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Thu, 15 Oct 2026 14:29:19 +0330 Subject: [PATCH] feat(engine): orchestrate the sing-box service --- internal/engine/engine.go | 143 +++++++++++++++++++++++++++++++++ internal/engine/engine_test.go | 94 ++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 internal/engine/engine.go create mode 100644 internal/engine/engine_test.go diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..b9c576c --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT + +// Package engine controls the proxy engine (sing-box) through systemd. +package engine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "prx/internal/config" + "prx/internal/execx" +) + +// IsActive reports whether the systemd unit is active. +func IsActive(ctx context.Context, r execx.Runner, service string) (bool, error) { + res, err := r.Run(ctx, nil, "systemctl", "is-active", service) + state := strings.TrimSpace(string(res.Stdout)) + if err != nil { + var ee *execx.ExitError + if errors.As(err, &ee) { + // inactive/failed units exit non-zero; that is not an error here. + return state == "active", nil + } + return false, err + } + return state == "active", nil +} + +// Start starts the unit. +func Start(ctx context.Context, r execx.Runner, service string) error { + _, err := r.Run(ctx, nil, "systemctl", "start", service) + if err != nil { + return fmt.Errorf("start %s: %w", service, err) + } + return nil +} + +// Stop stops the unit. +func Stop(ctx context.Context, r execx.Runner, service string) error { + _, err := r.Run(ctx, nil, "systemctl", "stop", service) + if err != nil { + return fmt.Errorf("stop %s: %w", service, err) + } + return nil +} + +// Reload asks the unit to reload its configuration. +func Reload(ctx context.Context, r execx.Runner, service string) error { + _, err := r.Run(ctx, nil, "systemctl", "reload", service) + if err != nil { + return fmt.Errorf("reload %s: %w", service, err) + } + return nil +} + +// ValidateConfig inspects a sing-box configuration for the settings prx relies +// on, returning human readable warnings (never fatal). +func ValidateConfig(path string) ([]string, error) { + if path == "" { + return nil, nil + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var parsed struct { + Inbounds []struct { + Type string `json:"type"` + } `json:"inbounds"` + Route struct { + AutoRoute *bool `json:"auto_route"` + } `json:"route"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("parse engine config %s: %w", path, err) + } + var warnings []string + hasTun := false + for _, in := range parsed.Inbounds { + if in.Type == "tun" { + hasTun = true + } + } + if !hasTun { + warnings = append(warnings, "engine config has no tun inbound; proxy routing expects TUN mode") + } + if parsed.Route.AutoRoute != nil && !*parsed.Route.AutoRoute { + warnings = append(warnings, "engine route.auto_route is false; the tunnel may not capture traffic") + } + return warnings, nil +} + +// Template renders a baseline sing-box configuration derived from cfg. +func Template(cfg *config.Config) ([]byte, error) { + directRule := map[string]any{ + "action": "route", + "outbound": "direct", + } + if len(cfg.Bypass.AlwaysCIDRs) > 0 || len(cfg.Bypass.ExtraCIDRs) > 0 { + directRule["ip_cidr"] = append(append([]string{}, cfg.Bypass.AlwaysCIDRs...), cfg.Bypass.ExtraCIDRs...) + } + if len(cfg.Bypass.Domains) > 0 { + directRule["domain"] = cfg.Bypass.Domains + } + if len(cfg.ProcessBypass.Names) > 0 { + directRule["process_name"] = cfg.ProcessBypass.Names + } + + doc := map[string]any{ + "log": map[string]any{"level": "info"}, + "inbounds": []any{ + map[string]any{ + "type": "tun", + "tag": "tun-in", + "interface_name": "tun0", + "address": []string{"172.19.0.1/30", "fdfe:dcba:9876::1/126"}, + "mtu": 9000, + "auto_route": true, + "strict_route": true, + "stack": "system", + "auto_redirect": false, + }, + }, + "outbounds": []any{ + // Replace "direct" with your proxy outbound (e.g. { "type": "shadowsocks", ... }). + map[string]any{"type": "direct", "tag": "direct"}, + }, + "route": map[string]any{ + "auto_detect_interface": true, + "rules": []any{directRule}, + "final": "direct", + }, + } + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..4102a80 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT + +package engine + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "prx/internal/config" + "prx/internal/execx" +) + +func TestIsActive(t *testing.T) { + active := &execx.Fake{Handler: func(_ string, _ []string, _ []byte) (execx.Result, error) { + return execx.Result{Stdout: []byte("active\n")}, nil + }} + got, err := IsActive(context.Background(), active, "sing-box") + if err != nil || !got { + t.Fatalf("IsActive = %v, %v", got, err) + } + + inactive := &execx.Fake{Handler: func(_ string, _ []string, _ []byte) (execx.Result, error) { + return execx.Result{Stdout: []byte("inactive\n"), Code: 3}, &execx.ExitError{Name: "systemctl", Code: 3, Stderr: []byte("inactive")} + }} + got, err = IsActive(context.Background(), inactive, "sing-box") + if err != nil || got { + t.Fatalf("IsActive(inactive) = %v, %v", got, err) + } +} + +func TestValidateConfig(t *testing.T) { + dir := t.TempDir() + withTun := filepath.Join(dir, "with-tun.json") + if err := os.WriteFile(withTun, []byte(`{"inbounds":[{"type":"tun"}]}`), 0o644); err != nil { + t.Fatal(err) + } + warnings, err := ValidateConfig(withTun) + if err != nil { + t.Fatalf("ValidateConfig: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + + noTun := filepath.Join(dir, "no-tun.json") + if err := os.WriteFile(noTun, []byte(`{"inbounds":[{"type":"mixed"}]}`), 0o644); err != nil { + t.Fatal(err) + } + warnings, err = ValidateConfig(noTun) + if err != nil { + t.Fatalf("ValidateConfig: %v", err) + } + if len(warnings) == 0 { + t.Fatal("expected a warning for missing tun inbound") + } +} + +func TestValidateConfigWarnsOnAutoRouteDisabled(t *testing.T) { + path := filepath.Join(t.TempDir(), "auto-route-off.json") + body := `{"inbounds":[{"type":"tun"}],"route":{"auto_route":false}}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + warnings, err := ValidateConfig(path) + if err != nil { + t.Fatalf("ValidateConfig: %v", err) + } + found := false + for _, w := range warnings { + if strings.Contains(w, "auto_route") { + found = true + } + } + if !found { + t.Fatalf("expected an auto_route warning, got %v", warnings) + } +} + +func TestTemplateContainsTunAndBypass(t *testing.T) { + cfg := config.Default() + body, err := Template(cfg) + if err != nil { + t.Fatalf("Template: %v", err) + } + s := string(body) + for _, needle := range []string{`"type": "tun"`, `"auto_route": true`, "10.0.0.0/8", "192.168.0.0/16"} { + if !strings.Contains(s, needle) { + t.Errorf("template missing %q", needle) + } + } +}