From 653d21e6c1654fa11be8654dabbd6d98304672cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Wed, 9 Jul 2025 10:45:18 +0200 Subject: [PATCH 1/2] refactor: Implement core functionality for AutoExitNode and split into modules - Added network management features in `network.go` to retrieve current SSID and check cellular connectivity. - Introduced startup management in `startup.go` to create, check, and remove startup shortcuts for the application. - Integrated Tailscale management in `tailscale.go` to activate and deactivate exit nodes based on network conditions. - Developed system tray functionality in `tray.go` to display network status, manage startup options, and handle updates. - Implemented update checking and notification system in `update.go` to inform users of new releases via GitHub API. --- .gitattributes | 1 + .github/workflows/build.yml | 33 ++ .github/workflows/release.yml | 4 +- .gitignore | 19 + README.md | 15 +- icon_active.ico => assets/icon_active.ico | Bin icon_active.png => assets/icon_active.png | Bin icon_inactive.ico => assets/icon_inactive.ico | Bin icon_inactive.png => assets/icon_inactive.png | Bin config.json | 1 + main.go | 384 +----------------- network.go | 53 +++ startup.go | 69 ++++ tailscale.go | 52 +++ tray.go | 149 +++++++ update.go | 68 ++++ 16 files changed, 467 insertions(+), 381 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore rename icon_active.ico => assets/icon_active.ico (100%) rename icon_active.png => assets/icon_active.png (100%) rename icon_inactive.ico => assets/icon_inactive.ico (100%) rename icon_inactive.png => assets/icon_inactive.png (100%) create mode 100644 network.go create mode 100644 startup.go create mode 100644 tailscale.go create mode 100644 tray.go create mode 100644 update.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b9f8c53 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: Build + +on: + workflow_dispatch: + pull_request: + types: [opened, reopened, synchronize] + +env: + COMPONENT_DIR: hsem + +jobs: + build: + name: Prepare build asset + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '^1.24.2' + + - name: Install rsrc to embed icon into application + run: | + go install github.com/akavel/rsrc@latest + + - name: Generate Windows resource file with icon + run: | + $HOME/go/bin/rsrc -ico assets/icon_active.ico -o resource_windows.syso + + - name: Build AutoExitNode for windows amd64 + run: | + GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o AutoExitNode.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1a6513..a17720c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: 'stable' + go-version: '^1.24.2' - name: Update version in main.go to ${{ github.event.release.tag_name }} run: | @@ -38,7 +38,7 @@ jobs: - name: Generate Windows resource file with icon run: | - $HOME/go/bin/rsrc -ico icon_active.ico -o resource_windows.syso + $HOME/go/bin/rsrc -ico assets/icon_active.ico -o resource_windows.syso - name: Build AutoExitNode for windows amd64 run: | diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..083217b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* +bin/ + +# misc +.coverage +coverage.xml +notes.txt +.venv +.DS_Store +scripts/sync.* + +# Home Assistant configuration +config/* +!config/configuration.yaml diff --git a/README.md b/README.md index 611030b..cc27f31 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License][license-shield]][license] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] -![Icon](icon_active.png) +![Icon](assets/icon_active.png) **AutoExitNode** is a Windows system tray application that automatically manages your Tailscale exit node based on your network (WiFi SSID or cellular connection). @@ -32,16 +32,15 @@ 3. **Place the program and icons** Make sure the following files are in the same folder: - `AutoExitNode.exe` - - `icon_active.ico` - - `icon_inactive.ico` - `config.json` (optional, see below) 4. **(Optional) Edit config.json** Example `config.json`: ```json { - "trustedSSIDs": ["Yoda-Fi", "R2D2-Fi"], - "exitNodes": ["homeassistant", "router", "vpn-node"] + "tailscalePath": "C:\\Program Files\\Tailscale\\tailscale.exe", + "trustedSSIDs": ["Yoda-Fi", "R2D2-Fi"], + "exitNodes": ["homeassistant", "router", "vpn-node"] } ``` @@ -51,7 +50,7 @@ - **Trusted SSID:** Disables exit node - **Untrusted SSID/Cellular:** Enables exit node (first in the config list) - The tray menu shows status, version, and provides access to: - - Force Sync (trigger immediate update) + - Force Sync (trigger immediate check for network and tailscale status) - Run at startup (autostart) - Check for update (checks GitHub for new version) - Quit (exit the app) @@ -64,8 +63,8 @@ ## Icons -- `icon_active.ico` (blue): Shown when exit node is active -- `icon_inactive.ico` (gray): Shown when exit node is inactive +- `assets/icon_active.ico` (blue): Shown when exit node is active +- `assets/icon_inactive.ico` (gray): Shown when exit node is inactive ## Updates diff --git a/icon_active.ico b/assets/icon_active.ico similarity index 100% rename from icon_active.ico rename to assets/icon_active.ico diff --git a/icon_active.png b/assets/icon_active.png similarity index 100% rename from icon_active.png rename to assets/icon_active.png diff --git a/icon_inactive.ico b/assets/icon_inactive.ico similarity index 100% rename from icon_inactive.ico rename to assets/icon_inactive.ico diff --git a/icon_inactive.png b/assets/icon_inactive.png similarity index 100% rename from icon_inactive.png rename to assets/icon_inactive.png diff --git a/config.json b/config.json index fe1b79e..bbf90d1 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,5 @@ { + "tailscalePath": "C:\\Program Files\\Tailscale\\tailscale.exe", "trustedSSIDs": [ "Yoda-Fi", "R2D2-Fi" diff --git a/main.go b/main.go index 8464677..03d7c58 100644 --- a/main.go +++ b/main.go @@ -3,404 +3,46 @@ package main import ( _ "embed" "encoding/json" - "errors" - "fmt" - "net/http" "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "syscall" - "time" "github.com/getlantern/systray" - "github.com/go-ole/go-ole" - "github.com/go-ole/go-ole/oleutil" ) -//go:embed icon_active.ico +//go:embed assets/icon_active.ico var iconActive []byte -//go:embed icon_inactive.ico +//go:embed assets/icon_inactive.ico var iconInactive []byte type Config struct { - TrustedSSIDs []string `json:"trustedSSIDs"` - ExitNodes []string `json:"exitNodes"` + TailscalePath string `json:"tailscalePath"` + TrustedSSIDs []string `json:"trustedSSIDs"` + ExitNodes []string `json:"exitNodes"` } var config Config -var tailscalePath = "C:\\Program Files\\Tailscale\\tailscale.exe" - -var lastSSID string -var lastCellular bool -var lastCommand string // "activated" or "deactivated" -var tailscaleAvailable = true - -var currentVersion = "v1.3.0" // Default version, will be overwritten by config if present - -var latestVersion string -var latestVersionURL string +var tailscalePath string +var currentVersion = "v1.3.0" +var startupDir = os.Getenv("APPDATA") + `\Microsoft\Windows\Start Menu\Programs\Startup` func main() { loadConfig() tailscaleAvailable = checkTailscaleExists() - systray.Run(onReady, nil) + systray.Run(autoExitNote, nil) } func loadConfig() { // Default config config = Config{ - TrustedSSIDs: []string{"Yoda-Fi", "R2D2-Fi"}, - ExitNodes: []string{"homeassistant", "router", "vpn-node"}, + TailscalePath: "C:\\Program Files\\Tailscale\\tailscale.exe", + TrustedSSIDs: []string{"Yoda-Fi", "R2D2-Fi"}, + ExitNodes: []string{"homeassistant", "router", "vpn-node"}, } f, err := os.Open("config.json") if err == nil { defer f.Close() _ = json.NewDecoder(f).Decode(&config) } -} - -func checkTailscaleExists() bool { - _, err := os.Stat(tailscalePath) - return err == nil -} - -func onReady() { - // Status label - mStatus := systray.AddMenuItem("Status: Initializing...", "Current network status") - mStatus.Disable() - - // Version label - mVersion := systray.AddMenuItem(fmt.Sprintf("Version: %s", currentVersion), "Current version") - mVersion.Disable() - - mForce := systray.AddMenuItem("Force Sync", "Force immediate sync") - mRunAtStartup := systray.AddMenuItemCheckbox("Run at startup", "Toggle auto-start", isStartupEnabled()) - mCheckUpdate := systray.AddMenuItem("Check for update", "Check for new version") - mQuit := systray.AddMenuItem("Quit", "Exit the app") - - if !tailscaleAvailable { - mForce.Disable() - systray.SetIcon(iconInactive) - systray.SetTooltip("Tailscale not found! Please install Tailscale.") - } else { - systray.SetIcon(iconInactive) - systray.SetTooltip("AutoExitNode - Tailscale controller") - } - - go func() { - for { - select { - case <-mForce.ClickedCh: - checkAndApply(mStatus) - case <-mRunAtStartup.ClickedCh: - if isStartupEnabled() { - removeStartupShortcut() - mRunAtStartup.Uncheck() - } else { - addStartupShortcut() - mRunAtStartup.Check() - } - case <-mCheckUpdate.ClickedCh: - go func() { - checkForUpdate(func(ver, url string) { - updateVersionMenu(mVersion, ver, url) - }) - }() - case <-mQuit.ClickedCh: - systray.Quit() - return - } - } - }() - - go func() { - for { - checkAndApply(mStatus) - time.Sleep(15 * time.Second) - } - }() - - // Periodically check for updates in the background - go func() { - for { - checkForUpdate(func(ver, url string) { - updateVersionMenu(mVersion, ver, url) - }) - time.Sleep(15 * time.Minute) - } - }() - - // Initial update check at startup - go checkForUpdate(func(ver, url string) { - updateVersionMenu(mVersion, ver, url) - }) -} - -// Update the version menu item if a new version is available -func updateVersionMenu(mVersion *systray.MenuItem, ver, url string) { - if ver != "" && ver != currentVersion { - mVersion.SetTitle(fmt.Sprintf("Version: %s (Update: %s)", currentVersion, ver)) - mVersion.SetTooltip(fmt.Sprintf("New version available: %s\n%s", ver, url)) - } else { - mVersion.SetTitle(fmt.Sprintf("Version: %s", currentVersion)) - mVersion.SetTooltip("Current version") - } -} - -func checkAndApply(mStatus *systray.MenuItem) { - if !tailscaleAvailable { - return - } - ssid, err := getCurrentSSID() - cell := isCellularConnected() - - // Status label logic - statusText := "" - tooltip := "" - var icon []byte = iconInactive - command := "" - - switch { - case cell: - statusText = "Cellular" - tooltip = fmt.Sprintf("Active: %s via cellular", getExitNodeName()) - icon = iconActive - command = "activated" - case err != nil || ssid == "": - statusText = "Untrusted SSID" - tooltip = fmt.Sprintf("Active: %s (unknown network)", getExitNodeName()) - icon = iconActive - command = "activated" - case isSSIDTrusted(ssid): - statusText = fmt.Sprintf("Trusted SSID: %s", ssid) - tooltip = fmt.Sprintf("Inactive: trusted network (%s)", ssid) - icon = iconInactive - command = "deactivated" - default: - statusText = "Untrusted SSID" - tooltip = fmt.Sprintf("Active: %s (untrusted SSID)", getExitNodeName()) - icon = iconActive - command = "activated" - } - - // Update tray label, icon, tooltip - mStatus.SetTitle("Status: " + statusText) - systray.SetIcon(icon) - systray.SetTooltip(tooltip) - - // Rate limiting: skip if nothing changed - if ssid == lastSSID && cell == lastCellular && lastCommand == command { - return - } - lastSSID = ssid - lastCellular = cell - - // Only run tailscale if command changed - if lastCommand != command { - if command == "activated" { - fmt.Println("[Activate] via", statusText) - activateExitNode() - } else { - fmt.Println("[Deactivate] via", statusText) - deactivateExitNode() - } - lastCommand = command - } -} - -func getExitNodeName() string { - // Try each exit node in config, return first that works (for now, just return first) - if len(config.ExitNodes) > 0 { - return config.ExitNodes[0] - } - return "homeassistant" -} - -func getCurrentSSID() (string, error) { - cmd := exec.Command("netsh", "wlan", "show", "interfaces") - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - output, err := cmd.Output() - if err != nil { - return "", err - } - - re := regexp.MustCompile(`^\s*SSID\s*:\s(.*)$`) - for _, line := range strings.Split(string(output), "\n") { - if matches := re.FindStringSubmatch(line); len(matches) > 1 { - return strings.TrimSpace(matches[1]), nil - } - } - return "", errors.New("SSID not found") -} - -func isCellularConnected() bool { - cmd := exec.Command("netsh", "mbn", "show", "interfaces") - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - output, err := cmd.Output() - if err != nil { - return false - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if strings.Contains(strings.ToLower(line), "state") && strings.Contains(strings.ToLower(line), "connected") { - return true - } - } - return false -} - -func isSSIDTrusted(ssid string) bool { - for _, trusted := range config.TrustedSSIDs { - if strings.EqualFold(ssid, trusted) { - return true - } - } - return false -} - -func activateExitNode() { - cmd := exec.Command(tailscalePath, - "up", - "--exit-node="+getExitNodeName(), - "--accept-dns=true", - "--shields-up") - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - output, err := cmd.CombinedOutput() - fmt.Println("Activate output:", string(output)) - if err != nil { - fmt.Println("Activate error:", err) - } -} - -func deactivateExitNode() { - cmd := exec.Command(tailscalePath, - "up", - "--exit-node=", - "--accept-dns=false", - "--shields-up") - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - output, err := cmd.CombinedOutput() - fmt.Println("Deactivate output:", string(output)) - if err != nil { - fmt.Println("Deactivate error:", err) - } -} - -func getStartupShortcutPath() string { - startupDir := os.Getenv("APPDATA") + `\Microsoft\Windows\Start Menu\Programs\Startup` - return filepath.Join(startupDir, "AutoExitNode.lnk") -} - -func isStartupEnabled() bool { - _, err := os.Stat(getStartupShortcutPath()) - return err == nil -} - -func addStartupShortcut() { - ole.CoInitialize(0) - defer ole.CoUninitialize() - - exePath, err := os.Executable() - if err != nil { - fmt.Println("Error getting executable path:", err) - return - } - - shellObj, err := oleutil.CreateObject("WScript.Shell") - if err != nil { - fmt.Println("CreateObject error:", err) - return - } - defer shellObj.Release() - - wshell, err := shellObj.QueryInterface(ole.IID_IDispatch) - if err != nil { - fmt.Println("QueryInterface error:", err) - return - } - defer wshell.Release() - - shortcutPath := getStartupShortcutPath() - sc, err := oleutil.CallMethod(wshell, "CreateShortcut", shortcutPath) - if err != nil { - fmt.Println("CreateShortcut error:", err) - return - } - defer sc.Clear() - - shortcut := sc.ToIDispatch() - _, _ = oleutil.PutProperty(shortcut, "TargetPath", exePath) - _, _ = oleutil.PutProperty(shortcut, "WorkingDirectory", filepath.Dir(exePath)) - _, _ = oleutil.PutProperty(shortcut, "WindowStyle", 7) - _, _ = oleutil.CallMethod(shortcut, "Save") -} - -func removeStartupShortcut() { - path := getStartupShortcutPath() - if err := os.Remove(path); err != nil { - fmt.Println("Failed to remove startup shortcut:", err) - } -} - -func checkForUpdate(cb func(version, url string)) { - const repo = "woopstar/AutoExitNode" // Ensure this matches your GitHub repo (owner/repo) - url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - fmt.Println("checkForUpdate: failed to create request:", err) - return - } - req.Header.Set("Accept", "application/vnd.github+json") - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Do(req) - if err != nil { - fmt.Println("checkForUpdate: HTTP request failed:", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - fmt.Printf("checkForUpdate: unexpected status code: %d\n", resp.StatusCode) - return - } - - var data struct { - TagName string `json:"tag_name"` - HTMLURL string `json:"html_url"` - } - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - fmt.Println("checkForUpdate: failed to decode JSON:", err) - return - } - - if data.TagName != "" && data.TagName != currentVersion { - latestVersion = data.TagName - latestVersionURL = data.HTMLURL - showWindowsNotification("Update available!", fmt.Sprintf("New version: %s\nSee: %s", data.TagName, data.HTMLURL)) - if cb != nil { - cb(data.TagName, data.HTMLURL) - } - } else if cb != nil { - cb("", "") - } -} - -// showWindowsNotification displays a notification on Windows using go-toast. -func showWindowsNotification(title, message string) { - // Show a simple popup using Windows MessageBox via PowerShell for maximum compatibility. - cmd := exec.Command("powershell", "-Command", fmt.Sprintf(`Add-Type -AssemblyName PresentationFramework;[System.Windows.MessageBox]::Show('%s', '%s')`, escapeForPowerShell(message), escapeForPowerShell(title))) - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - if err := cmd.Run(); err != nil { - fmt.Println("showWindowsNotification: failed to show popup:", err) - } -} -// escapeForPowerShell escapes single quotes for PowerShell string literals. -func escapeForPowerShell(s string) string { - return strings.ReplaceAll(s, "'", "''") + tailscalePath = config.TailscalePath } diff --git a/network.go b/network.go new file mode 100644 index 0000000..56151ae --- /dev/null +++ b/network.go @@ -0,0 +1,53 @@ +package main + +import ( + "errors" + "os/exec" + "regexp" + "strings" + "syscall" +) + +// getCurrentSSID returns the current WiFi SSID or an error if not found. +func getCurrentSSID() (string, error) { + cmd := exec.Command("netsh", "wlan", "show", "interfaces") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + output, err := cmd.Output() + if err != nil { + return "", err + } + re := regexp.MustCompile(`^\s*SSID\s*:\s(.*)$`) + for _, line := range strings.Split(string(output), "\n") { + if matches := re.FindStringSubmatch(line); len(matches) > 1 { + return strings.TrimSpace(matches[1]), nil + } + } + return "", errors.New("SSID not found") +} + +// isCellularConnected returns true if a cellular interface is connected. +func isCellularConnected() bool { + cmd := exec.Command("netsh", "mbn", "show", "interfaces") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + output, err := cmd.Output() + if err != nil { + return false + } + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(strings.ToLower(line), "state") && strings.Contains(strings.ToLower(line), "connected") { + return true + } + } + return false +} + +// isSSIDTrusted checks if the given SSID is in the trusted list. +func isSSIDTrusted(ssid string) bool { + for _, trusted := range config.TrustedSSIDs { + if strings.EqualFold(ssid, trusted) { + return true + } + } + return false +} diff --git a/startup.go b/startup.go new file mode 100644 index 0000000..58b196c --- /dev/null +++ b/startup.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" +) + +// getStartupShortcutPath returns the path to the startup shortcut. +func getStartupShortcutPath() string { + return filepath.Join(startupDir, "AutoExitNode.lnk") +} + +// isStartupEnabled checks if the startup shortcut exists. +func isStartupEnabled() bool { + _, err := os.Stat(getStartupShortcutPath()) + return err == nil +} + +// addStartupShortcut creates a Windows shortcut for autostart. +func addStartupShortcut() { + ole.CoInitialize(0) + defer ole.CoUninitialize() + + exePath, err := os.Executable() + if err != nil { + fmt.Println("Error getting executable path:", err) + return + } + + shellObj, err := oleutil.CreateObject("WScript.Shell") + if err != nil { + fmt.Println("CreateObject error:", err) + return + } + defer shellObj.Release() + + wshell, err := shellObj.QueryInterface(ole.IID_IDispatch) + if err != nil { + fmt.Println("QueryInterface error:", err) + return + } + defer wshell.Release() + + shortcutPath := getStartupShortcutPath() + sc, err := oleutil.CallMethod(wshell, "CreateShortcut", shortcutPath) + if err != nil { + fmt.Println("CreateShortcut error:", err) + return + } + defer sc.Clear() + + shortcut := sc.ToIDispatch() + _, _ = oleutil.PutProperty(shortcut, "TargetPath", exePath) + _, _ = oleutil.PutProperty(shortcut, "WorkingDirectory", filepath.Dir(exePath)) + _, _ = oleutil.PutProperty(shortcut, "WindowStyle", 7) + _, _ = oleutil.CallMethod(shortcut, "Save") +} + +// removeStartupShortcut deletes the autostart shortcut. +func removeStartupShortcut() { + path := getStartupShortcutPath() + if err := os.Remove(path); err != nil { + fmt.Println("Failed to remove startup shortcut:", err) + } +} diff --git a/tailscale.go b/tailscale.go new file mode 100644 index 0000000..6d1ec19 --- /dev/null +++ b/tailscale.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "syscall" +) + +// checkTailscaleExists returns true if the Tailscale binary exists. +func checkTailscaleExists() bool { + _, err := os.Stat(tailscalePath) + return err == nil +} + +// getExitNodeName returns the first exit node from config or a default. +func getExitNodeName() string { + if len(config.ExitNodes) > 0 { + return config.ExitNodes[0] + } + return "homeassistant" +} + +// activateExitNode runs tailscale up with exit node. +func activateExitNode() { + cmd := exec.Command(tailscalePath, + "up", + "--exit-node="+getExitNodeName(), + "--accept-dns=true", + "--shields-up") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + output, err := cmd.CombinedOutput() + fmt.Println("Activate output:", string(output)) + if err != nil { + fmt.Println("Activate error:", err) + } +} + +// deactivateExitNode disables exit node. +func deactivateExitNode() { + cmd := exec.Command(tailscalePath, + "up", + "--exit-node=", + "--accept-dns=false", + "--shields-up") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + output, err := cmd.CombinedOutput() + fmt.Println("Deactivate output:", string(output)) + if err != nil { + fmt.Println("Deactivate error:", err) + } +} diff --git a/tray.go b/tray.go new file mode 100644 index 0000000..25a3091 --- /dev/null +++ b/tray.go @@ -0,0 +1,149 @@ +package main + +import ( + "fmt" + "math/rand" + "time" + + "github.com/getlantern/systray" +) + +var lastSSID string +var lastCellular bool +var lastCommand string +var tailscaleAvailable = true +var checkInterval = 15 * time.Second +var updateInterval = time.Duration(rand.Intn(60)+1) * time.Minute + +func autoExitNote() { + mStatus := systray.AddMenuItem("Status: Initializing...", "Current network status") + mStatus.Disable() + + mVersion := systray.AddMenuItem(fmt.Sprintf("Version: %s", currentVersion), "Current version") + mVersion.Disable() + + mForce := systray.AddMenuItem("Force Sync", "Force immediate sync") + mRunAtStartup := systray.AddMenuItemCheckbox("Run at startup", "Toggle auto-start", isStartupEnabled()) + mCheckUpdate := systray.AddMenuItem("Check for update", "Check for new version") + mQuit := systray.AddMenuItem("Quit", "Exit the app") + + if !tailscaleAvailable { + mForce.Disable() + systray.SetIcon(iconInactive) + systray.SetTooltip("Tailscale not found! Please install Tailscale.") + } else { + systray.SetIcon(iconInactive) + systray.SetTooltip("AutoExitNode - Tailscale controller") + } + + go func() { + for { + select { + case <-mForce.ClickedCh: + checkAndApply(mStatus) + case <-mRunAtStartup.ClickedCh: + if isStartupEnabled() { + removeStartupShortcut() + mRunAtStartup.Uncheck() + } else { + addStartupShortcut() + mRunAtStartup.Check() + } + case <-mCheckUpdate.ClickedCh: + go func() { + checkForUpdate(func(ver, url string) { + updateVersionMenu(mVersion, ver, url) + }) + }() + case <-mQuit.ClickedCh: + systray.Quit() + return + } + } + }() + + go func() { + for { + checkAndApply(mStatus) + time.Sleep(checkInterval) + } + }() + + go func() { + for { + checkForUpdate(func(ver, url string) { + updateVersionMenu(mVersion, ver, url) + }) + time.Sleep(updateInterval) + } + }() +} + +// checkAndApply handles the main logic for tray status and tailscale actions. +func checkAndApply(mStatus *systray.MenuItem) { + if !tailscaleAvailable { + return + } + ssid, err := getCurrentSSID() + cell := isCellularConnected() + + statusText := "" + tooltip := "" + var icon []byte = iconInactive + command := "" + + switch { + case cell: + statusText = "Cellular" + tooltip = fmt.Sprintf("Active: %s via cellular", getExitNodeName()) + icon = iconActive + command = "activated" + case err != nil || ssid == "": + statusText = "Untrusted SSID" + tooltip = fmt.Sprintf("Active: %s (unknown network)", getExitNodeName()) + icon = iconActive + command = "activated" + case isSSIDTrusted(ssid): + statusText = fmt.Sprintf("Trusted SSID: %s", ssid) + tooltip = fmt.Sprintf("Inactive: trusted network (%s)", ssid) + icon = iconInactive + command = "deactivated" + default: + statusText = "Untrusted SSID" + tooltip = fmt.Sprintf("Active: %s (untrusted SSID)", getExitNodeName()) + icon = iconActive + command = "activated" + } + + mStatus.SetTitle("Status: " + statusText) + systray.SetIcon(icon) + systray.SetTooltip(tooltip) + + if ssid == lastSSID && cell == lastCellular && lastCommand == command { + return + } + lastSSID = ssid + lastCellular = cell + + if lastCommand != command { + if command == "activated" { + fmt.Println("[Activate] via", statusText) + activateExitNode() + } else { + fmt.Println("[Deactivate] via", statusText) + deactivateExitNode() + } + lastCommand = command + } +} + +// updateVersionMenu updates the version menu item if a new version is available. +func updateVersionMenu(mVersion *systray.MenuItem, ver, url string) { + if ver != "" && ver != currentVersion { + mVersion.SetTitle(fmt.Sprintf("Version: %s (Update: %s)", currentVersion, ver)) + mVersion.SetTooltip(fmt.Sprintf("New version available: %s\n%s", ver, url)) + } else { + mVersion.SetTitle(fmt.Sprintf("Version: %s", currentVersion)) + mVersion.SetTooltip("Current version") + } +} diff --git a/update.go b/update.go new file mode 100644 index 0000000..16ff84d --- /dev/null +++ b/update.go @@ -0,0 +1,68 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os/exec" + "strings" + "syscall" + "time" +) + +// checkForUpdate checks for a new release and calls cb if found. +func checkForUpdate(cb func(version, url string)) { + const repo = "woopstar/AutoExitNode" + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + fmt.Println("checkForUpdate: failed to create request:", err) + return + } + req.Header.Set("Accept", "application/vnd.github+json") + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Println("checkForUpdate: HTTP request failed:", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Printf("checkForUpdate: unexpected status code: %d\n", resp.StatusCode) + return + } + + var data struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + fmt.Println("checkForUpdate: failed to decode JSON:", err) + return + } + + if data.TagName != "" && data.TagName != currentVersion { + showWindowsNotification("Update available!", fmt.Sprintf("New version: %s\nSee: %s", data.TagName, data.HTMLURL)) + if cb != nil { + cb(data.TagName, data.HTMLURL) + } + } else if cb != nil { + cb("", "") + } +} + +// showWindowsNotification displays a notification on Windows using PowerShell. +func showWindowsNotification(title, message string) { + cmd := exec.Command("powershell", "-Command", fmt.Sprintf(`Add-Type -AssemblyName PresentationFramework;[System.Windows.MessageBox]::Show('%s', '%s')`, escapeForPowerShell(message), escapeForPowerShell(title))) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if err := cmd.Run(); err != nil { + fmt.Println("showWindowsNotification: failed to show popup:", err) + } +} + +// escapeForPowerShell escapes single quotes for PowerShell string literals. +func escapeForPowerShell(s string) string { + return strings.ReplaceAll(s, "'", "''") +} From ec209f716969b58e94332b53018a218957d5adf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Wed, 9 Jul 2025 10:51:02 +0200 Subject: [PATCH 2/2] feat: Add permissions to GitHub workflows for enhanced access control --- .github/workflows/build.yml | 2 ++ .github/workflows/check-on-hold.yml | 2 ++ .github/workflows/defender-for-devops.yml | 2 ++ .github/workflows/release-drafter.yml | 2 ++ .github/workflows/release.yml | 2 ++ .github/workflows/sync-labels.yaml | 2 ++ 6 files changed, 12 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b9f8c53..7440717 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,7 @@ name: Build +permissions: read-all + on: workflow_dispatch: pull_request: diff --git a/.github/workflows/check-on-hold.yml b/.github/workflows/check-on-hold.yml index aea6049..e66f949 100644 --- a/.github/workflows/check-on-hold.yml +++ b/.github/workflows/check-on-hold.yml @@ -1,5 +1,7 @@ name: Check if PR is on hold +permissions: read-all + on: pull_request: types: diff --git a/.github/workflows/defender-for-devops.yml b/.github/workflows/defender-for-devops.yml index 364ca26..bd867e9 100644 --- a/.github/workflows/defender-for-devops.yml +++ b/.github/workflows/defender-for-devops.yml @@ -18,6 +18,8 @@ name: "Microsoft Defender For Devops" +permissions: read-all + on: push: branches: [ "main" ] diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 8e3346d..2aaba74 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,5 +1,7 @@ name: Release Drafter +permissions: read-all + on: schedule: - cron: "0 0 * * *" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a17720c..98036b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,7 @@ name: Release +permissions: read-all + on: workflow_dispatch: release: diff --git a/.github/workflows/sync-labels.yaml b/.github/workflows/sync-labels.yaml index aed2b90..0a6a2b1 100644 --- a/.github/workflows/sync-labels.yaml +++ b/.github/workflows/sync-labels.yaml @@ -1,5 +1,7 @@ name: Sync labels +permissions: read-all + # yamllint disable-line rule:truthy on: push: