feat(engine): orchestrate the sing-box service
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user