diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 30f3e34..277bba9 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -20,13 +20,44 @@ jobs: - name: Build for Windows run: GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" . + - name: Build for macOS Intel + run: | + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 \ + go build -trimpath -ldflags="-s -w" \ + -o firefly-go-proxy-macos-amd64 . + + - name: Build for macOS Apple Silicon + run: | + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 \ + go build -trimpath -ldflags="-s -w" \ + -o firefly-go-proxy-macos-arm64 . + - name: Grant execute permissions run: | chmod +x ./script/release-uploader - - name: Upload release + - name: Upload Windows release env: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} - run: script/release-uploader -token=$REPO_TOKEN -release-url="https://git.kain.io.vn/api/v1/repos/Firefly-Shelter/FireflyGo_Proxy/releases" -files="firefly-go-proxy.exe" + run: script/release-uploader \ + -token=$REPO_TOKEN \ + -release-url="https://git.kain.io.vn/api/v1/repos/Firefly-Shelter/FireflyGo_Proxy/releases" \ + -files="firefly-go-proxy.exe" + - name: Upload macOS Intel release + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + run: | + script/release-uploader \ + -token=$REPO_TOKEN \ + -release-url="https://git.kain.io.vn/api/v1/repos/Firefly-Shelter/FireflyGo_Proxy/releases" \ + -files="firefly-go-proxy-macos-amd64" + - name: Upload macOS ARM release + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + run: | + script/release-uploader \ + -token=$REPO_TOKEN \ + -release-url="https://git.kain.io.vn/api/v1/repos/Firefly-Shelter/FireflyGo_Proxy/releases" \ + -files="firefly-go-proxy-macos-arm64" diff --git a/admin_self_darwin.go b/admin_self_darwin.go index 153a458..5e1a88c 100644 --- a/admin_self_darwin.go +++ b/admin_self_darwin.go @@ -7,7 +7,10 @@ import ( "fmt" "os" "os/exec" + "os/signal" + "strconv" "strings" + "syscall" ) func relaunchWithAdminIfNeeded() (bool, error) { @@ -25,11 +28,17 @@ func relaunchWithAdminIfNeeded() (bool, error) { return false, fmt.Errorf("get working directory: %w", err) } + wrapperPID := os.Getpid() + launcherPID := os.Getppid() + args := make([]string, 0, len(os.Args)) args = append(args, shellQuote(exePath)) for _, arg := range os.Args[1:] { args = append(args, shellQuote(arg)) } + if !hasFlagArg("parent-pid") { + args = append(args, shellQuote("-parent-pid"), shellQuote(strconv.Itoa(wrapperPID))) + } command := fmt.Sprintf( "cd %s && %s > /dev/null 2>&1 &", @@ -42,5 +51,27 @@ func relaunchWithAdminIfNeeded() (bool, error) { return false, formatCommandError("relaunch proxy as admin", err, out) } + waitForRelaunchedProxyShutdown(launcherPID) return true, nil } + +func waitForRelaunchedProxyShutdown(launcherPID int) { + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(stop) + + select { + case <-stop: + case <-parentProcessDone(launcherPID): + } +} + +func hasFlagArg(name string) bool { + for _, arg := range os.Args[1:] { + trimmed := strings.TrimLeft(arg, "-") + if trimmed == name || strings.HasPrefix(trimmed, name+"=") { + return true + } + } + return false +} diff --git a/main.go b/main.go index ca6f917..ec741d5 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,7 @@ func main() { blockedStr := flag.String("b", "", "comma separated list of blocked ports") proxyPort := flag.Int("p", 0, "proxy listen port (default: auto)") exePath := flag.String("e", "", "path to the executable") + parentPID := flag.Int("parent-pid", 0, "parent process id to watch") flag.Parse() relaunched, err := relaunchWithAdminIfNeeded() @@ -74,8 +75,10 @@ func main() { proxyAddr := "127.0.0.1" proxyEndpoint := proxyAddr + ":" + port proxyEnabled := false + stopProxyRefresh := func() {} defer func() { + stopProxyRefresh() if r := recover(); r != nil { zlog.Error(). Interface("panic", r). @@ -93,6 +96,7 @@ func main() { return } proxyEnabled = true + stopProxyRefresh = startProxyRefreshLoop(proxyAddr, port) customCaMitm := &goproxy.ConnectAction{Action: goproxy.ConnectMitm, TLSConfig: goproxy.TLSConfigFromCA(cert)} var customAlwaysMitm goproxy.FuncHttpsHandler = func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) { @@ -124,6 +128,7 @@ func main() { } if matchDomain(host, AlwaysIgnoreDomains) { + zlog.Warn().Str("url", req.URL.String()).Msg("PASS URL") return req, nil } @@ -167,6 +172,7 @@ func main() { return req, nil } + zlog.Warn().Str("url", req.URL.String()).Msg("PASS URL") return req, nil }) @@ -182,6 +188,7 @@ func main() { stop := make(chan os.Signal, 1) serverErr := make(chan error, 1) + parentDone := parentProcessDone(*parentPID) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) if *exePath != "" && exists(*exePath) { go func() { @@ -210,6 +217,8 @@ func main() { case <-stop: case err := <-serverErr: zlog.Error().Err(err).Msg("ListenAndServe failed") + case <-parentDone: + zlog.Info().Int("ParentPID", *parentPID).Msg("Parent process exited") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/parent_process_darwin.go b/parent_process_darwin.go new file mode 100644 index 0000000..fe42026 --- /dev/null +++ b/parent_process_darwin.go @@ -0,0 +1,36 @@ +//go:build darwin +// +build darwin + +package main + +import ( + "syscall" + "time" +) + +func parentProcessDone(pid int) <-chan struct{} { + if pid <= 1 { + return nil + } + + done := make(chan struct{}) + go func() { + defer close(done) + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if !processExists(pid) { + return + } + } + }() + + return done +} + +func processExists(pid int) bool { + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} diff --git a/parent_process_other.go b/parent_process_other.go new file mode 100644 index 0000000..0f18c5c --- /dev/null +++ b/parent_process_other.go @@ -0,0 +1,8 @@ +//go:build !darwin +// +build !darwin + +package main + +func parentProcessDone(pid int) <-chan struct{} { + return nil +} diff --git a/proxy_refresh_darwin.go b/proxy_refresh_darwin.go new file mode 100644 index 0000000..35b4758 --- /dev/null +++ b/proxy_refresh_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin +// +build darwin + +package main + +import ( + "sync" + "time" + + zlog "github.com/rs/zerolog/log" +) + +func startProxyRefreshLoop(host string, port string) func() { + done := make(chan struct{}) + stopped := make(chan struct{}) + var once sync.Once + + go func() { + defer close(stopped) + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ticker.C: + if err := setProxy(true, host, port); err != nil { + zlog.Error().Err(err).Msg("Failed to refresh macOS proxy services") + } + } + } + }() + + return func() { + once.Do(func() { + close(done) + <-stopped + }) + } +} diff --git a/proxy_refresh_other.go b/proxy_refresh_other.go new file mode 100644 index 0000000..cb04aa6 --- /dev/null +++ b/proxy_refresh_other.go @@ -0,0 +1,8 @@ +//go:build !darwin +// +build !darwin + +package main + +func startProxyRefreshLoop(host string, port string) func() { + return func() {} +} diff --git a/script/README_Note.md b/script/README_Note.md index c41e14b..7cba9b4 100644 --- a/script/README_Note.md +++ b/script/README_Note.md @@ -1,4 +1,4 @@ # Changelog ### UPDATE -- Go 1.26.1, latest lib \ No newline at end of file +- Fix bug in macos \ No newline at end of file diff --git a/script/release.json b/script/release.json index c1e4f9e..107ac7f 100644 --- a/script/release.json +++ b/script/release.json @@ -1,5 +1,5 @@ { - "tag": "1.1-03", - "title": "PreBuild Version 1.1 - 03" + "tag": "1.2-01", + "title": "PreBuild Version 1.2 - 01" } \ No newline at end of file diff --git a/system_proxy_darwin.go b/system_proxy_darwin.go index 1937efc..b6861fa 100644 --- a/system_proxy_darwin.go +++ b/system_proxy_darwin.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os/exec" + "strconv" "strings" "sync" ) @@ -26,6 +27,7 @@ var darwinProxyState = struct { sync.Mutex captured bool previous map[string]darwinProxySettings + applied map[string]struct{} }{} func enabledNetworkServices() ([]string, error) { @@ -50,6 +52,171 @@ func enabledNetworkServices() ([]string, error) { return services, nil } +func proxyNetworkServices() ([]string, error) { + service, defaultErr := defaultNetworkService() + if defaultErr == nil && service != "" { + return []string{service}, nil + } + + services, activeErr := activeNetworkServices() + if activeErr == nil && len(services) > 0 { + return services, nil + } + + if defaultErr != nil || activeErr != nil { + return nil, errors.Join(defaultErr, activeErr) + } + return nil, fmt.Errorf("no active network services found") +} + +func defaultNetworkService() (string, error) { + device, err := defaultNetworkDevice() + if err != nil { + return "", err + } + + servicesByDevice, err := networkServicesByDevice() + if err != nil { + return "", err + } + + service := servicesByDevice[device] + if service == "" { + return "", fmt.Errorf("network service for default device %s not found", device) + } + return service, nil +} + +func defaultNetworkDevice() (string, error) { + out, err := exec.Command("route", "-n", "get", "default").CombinedOutput() + if err != nil { + return "", formatCommandError("get default network route", err, out) + } + + for _, line := range strings.Split(string(out), "\n") { + key, value, ok := strings.Cut(line, ":") + if ok && strings.TrimSpace(key) == "interface" { + device := strings.TrimSpace(value) + if device != "" { + return device, nil + } + } + } + + return "", fmt.Errorf("default network interface not found") +} + +func networkServicesByDevice() (map[string]string, error) { + out, err := exec.Command("networksetup", "-listnetworkserviceorder").CombinedOutput() + if err != nil { + return nil, formatCommandError("list network service order", err, out) + } + + services := make(map[string]string) + currentService := "" + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if service, ok := parseNetworkServiceOrderName(line); ok { + currentService = service + continue + } + if currentService == "" { + continue + } + if device, ok := parseNetworkServiceOrderDevice(line); ok { + services[device] = currentService + currentService = "" + } + } + + if len(services) == 0 { + return nil, fmt.Errorf("no network service devices found") + } + return services, nil +} + +func parseNetworkServiceOrderName(line string) (string, bool) { + if !strings.HasPrefix(line, "(") { + return "", false + } + + end := strings.Index(line, ")") + if end <= 1 { + return "", false + } + + if _, err := strconv.Atoi(line[1:end]); err != nil { + return "", false + } + + service := strings.TrimSpace(line[end+1:]) + return service, service != "" +} + +func parseNetworkServiceOrderDevice(line string) (string, bool) { + _, value, ok := strings.Cut(line, "Device:") + if !ok { + return "", false + } + + value = strings.TrimSpace(value) + if idx := strings.IndexAny(value, ",)"); idx != -1 { + value = value[:idx] + } + + device := strings.TrimSpace(value) + return device, device != "" +} + +func activeNetworkServices() ([]string, error) { + services, err := enabledNetworkServices() + if err != nil { + return nil, err + } + + active := make([]string, 0, len(services)) + for _, service := range services { + ok, err := isNetworkServiceActive(service) + if err != nil { + continue + } + if ok { + active = append(active, service) + } + } + + if len(active) == 0 { + return nil, fmt.Errorf("no active network services found") + } + return active, nil +} + +func isNetworkServiceActive(service string) (bool, error) { + out, err := exec.Command("networksetup", "-getinfo", service).CombinedOutput() + if err != nil { + return false, formatCommandError("get network service info "+service, err, out) + } + + for _, line := range strings.Split(string(out), "\n") { + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + + key = strings.TrimSpace(key) + if key != "IP address" && key != "IPv6 IP address" { + continue + } + + value = strings.TrimSpace(value) + if value != "" && !strings.EqualFold(value, "none") { + return true, nil + } + } + + return false, nil +} + func getProxySettings(service string) (darwinProxySettings, error) { web, webErr := getProxyEndpoint("-getwebproxy", service) secure, secureErr := getProxyEndpoint("-getsecurewebproxy", service) @@ -166,12 +333,86 @@ func captureProxySettings(services []string) (map[string]darwinProxySettings, er return settings, nil } -func setProxy(enable bool, host string, port string) error { - services, err := enabledNetworkServices() +func captureMissingProxySettings(services []string) error { + missing := make([]string, 0, len(services)) + for _, service := range services { + if _, ok := darwinProxyState.previous[service]; !ok { + missing = append(missing, service) + } + } + + if len(missing) == 0 { + return nil + } + + settings, err := captureProxySettings(missing) if err != nil { return err } + for service, setting := range settings { + darwinProxyState.previous[service] = setting + } + return nil +} +func serviceSet(services []string) map[string]struct{} { + set := make(map[string]struct{}, len(services)) + for _, service := range services { + set[service] = struct{}{} + } + return set +} + +func appliedServicesNotIn(selected map[string]struct{}) []string { + services := make([]string, 0, len(darwinProxyState.applied)) + for service := range darwinProxyState.applied { + if _, ok := selected[service]; !ok { + services = append(services, service) + } + } + return services +} + +func appliedServices() []string { + services := make([]string, 0, len(darwinProxyState.applied)) + for service := range darwinProxyState.applied { + services = append(services, service) + } + return services +} + +func proxySettingsForServices(settings map[string]darwinProxySettings, services []string) map[string]darwinProxySettings { + filtered := make(map[string]darwinProxySettings, len(services)) + for _, service := range services { + if setting, ok := settings[service]; ok { + filtered[service] = setting + } + } + return filtered +} + +func resetDarwinProxyState() { + darwinProxyState.previous = nil + darwinProxyState.applied = nil + darwinProxyState.captured = false +} + +func restoreAppliedProxySettings() error { + if len(darwinProxyState.applied) == 0 { + return nil + } + + err := restoreProxySettings(proxySettingsForServices( + darwinProxyState.previous, + appliedServices(), + )) + if err == nil { + darwinProxyState.applied = nil + } + return err +} + +func setProxy(enable bool, host string, port string) error { darwinProxyState.Lock() defer darwinProxyState.Unlock() @@ -180,32 +421,48 @@ func setProxy(enable bool, host string, port string) error { return fmt.Errorf("host and port are required to enable proxy") } - if !darwinProxyState.captured { - settings, err := captureProxySettings(services) - if err != nil { - return err - } - darwinProxyState.previous = settings - darwinProxyState.captured = true + services, err := proxyNetworkServices() + if err != nil { + return errors.Join(err, restoreAppliedProxySettings()) } + if darwinProxyState.previous == nil { + darwinProxyState.previous = make(map[string]darwinProxySettings) + } + if err := captureMissingProxySettings(services); err != nil { + return err + } + darwinProxyState.captured = true + if err := setProxyForServices(services, host, port); err != nil { restoreErr := restoreProxySettings(darwinProxyState.previous) - darwinProxyState.previous = nil - darwinProxyState.captured = false + resetDarwinProxyState() return errors.Join(err, restoreErr) } - return nil + + selected := serviceSet(services) + removed := appliedServicesNotIn(selected) + restoreErr := restoreProxySettings(proxySettingsForServices(darwinProxyState.previous, removed)) + if restoreErr != nil { + for _, service := range removed { + selected[service] = struct{}{} + } + } + darwinProxyState.applied = selected + return restoreErr } if darwinProxyState.captured { err := restoreProxySettings(darwinProxyState.previous) if err == nil { - darwinProxyState.previous = nil - darwinProxyState.captured = false + resetDarwinProxyState() } return err } + services, err := enabledNetworkServices() + if err != nil { + return err + } return disableProxyForServices(services) }