Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2025 Andreas Krüger

* @woopstar
4 changes: 4 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2025 Andreas Krüger

github: woopstar
buy_me_a_coffee: woopstar
30 changes: 30 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Custom GitHub Copilot Instructions

## Conventional Commits
- Always use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages and pull request titles.

## Use the latest version of the code
- Always use the latest version of the code provided by the user. If the user provides a file, use that file as the base for your changes. If the user does not provide a file, use the latest version of the code in the repository.

## Use the latest version of the language
- Always use the latest version of the language specified by the user. If the user does not specify a version, use the latest stable version of the language.

## Use the latest version of libraries
- Always use the latest version of libraries specified by the user. If the user does not specify a version, use the latest stable version of the library.

## Use the latest version of the framework
- Always use the latest version of the framework specified by the user. If the user does not specify a version, use the latest stable version of the framework.

## Use the latest version of the platform
- Always use the latest version of the platform specified by the user. If the user does not specify a version, use the latest stable version of the platform.

## Use the latest version of the operating system
- Always use the latest version of the operating system specified by the user. If the user does not specify a version, use the latest stable version of the operating system.

## Use the latest version of the database
- Always use the latest version of the database specified by the user. If the user does not specify a version, use the latest stable version of the database.

## Avoid triggering public code warnings

- Avoid generating code verbatim from public code examples. Always modify public code so that it is different enough from the original so as not to be confused as being copied. When you do so, provide a footnote to the user informing them.
Expand Down Expand Up @@ -29,6 +53,12 @@
- For libraries or external dependencies, mention their usage and purpose in comments.
- Use consistent naming conventions and follow language-specific best practices.
- Write concise, efficient, and idiomatic code that is also easily understandable.
- Use meaningful variable and function names that reflect their purpose.
- Include comments for complex logic or non-obvious code sections.
- Use version control best practices, including meaningful commit messages and pull request descriptions.
- Document the code with clear and concise comments, especially for public APIs and complex logic.
- Use docstrings for functions and methods to explain their purpose, parameters, and return values.
- Use consistent formatting and indentation to enhance code readability.

## Edge Cases and Testing

Expand Down
51 changes: 51 additions & 0 deletions .github/workflows/auto-label.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2025 Andreas Krüger

name: Auto Label PRs from Conventional Commits

on:
pull_request:
branches: [main]
types:
[opened, reopened, labeled, unlabeled]
workflow_dispatch:

permissions: read-all

jobs:
label:
name: Assign PR Labels
runs-on: ubuntu-latest
permissions: write-all
if: github.event.pull_request.merged == false
steps:
- uses: actions/checkout@v4

- name: Execute assign labels
id: action-assign-labels
uses: mauroalderete/action-assign-labels@v1
with:
pull-request-number: ${{ github.event.pull_request.number }}
github-token: ${{ secrets.GITHUB_TOKEN }}
conventional-commits: |
conventional-commits:
- type: 'fix'
nouns: ['FIX', 'Fix', 'fix', 'FIXED', 'Fixed', 'fixed']
labels: ['bug']
- type: 'feature'
nouns: ['FEATURE', 'Feature', 'feature', 'FEAT', 'Feat', 'feat']
labels: ['feature']
- type: 'breaking_change'
nouns: ['BREAKING CHANGE', 'BREAKING', 'MAJOR']
labels: ['BREAKING CHANGE']
- type: 'documentation'
nouns: ['doc','docu','document','documentation']
labels: ['documentation']
- type: 'build'
nouns: ['build','rebuild']
labels: ['build']
- type: 'config'
nouns: ['config', 'conf', 'cofiguration', 'configure']
labels: ['config']
maintain-labels-not-matched: false
apply-changes: true
30 changes: 30 additions & 0 deletions .github/workflows/gosec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: "Gosec Security Scan"

permissions: read-all

# Run workflow each time code is pushed to your repository and on a schedule.
# The scheduled workflow runs every at 00:00 on Sunday UTC time.
on:
push:
schedule:
- cron: '0 0 * * 0'

jobs:
gosec:
runs-on: ubuntu-latest
permissions: write-all
env:
GO111MODULE: on
steps:
- name: Checkout Source
uses: actions/checkout@v3
- name: Run Gosec Security Scanner
uses: securego/gosec@master
with:
# we let the report trigger content trigger a failure using the GitHub Security features.
args: '-no-fail -fmt sarif -out results.sarif ./...'
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v2
with:
# Path to SARIF file relative to the root of the repository
sarif_file: results.sarif
18 changes: 18 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [

{
"name": "Launch",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"env": {},
"args": []
}
]
}
16 changes: 16 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Build Go",
"type": "shell",
"command": "go build -ldflags=\"-H windowsgui\" -o AutoExitNode.exe",
"group": {
"kind": "build",
"isDefault": true
}
}
]
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ var startupDir = os.Getenv("APPDATA") + `\Microsoft\Windows\Start Menu\Programs\

func main() {
loadConfig()
tailscaleAvailable = checkTailscaleExists()
tailscaleAvailable = isValidTailscalePath(tailscalePath)
systray.Run(autoExitNote, nil)
}

Expand Down
27 changes: 27 additions & 0 deletions network.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package main

import (
"errors"
"fmt"
"net"
"os/exec"
"regexp"
"strings"
"syscall"
"time"
)

// getCurrentSSID returns the current WiFi SSID or an error if not found.
Expand Down Expand Up @@ -51,3 +54,27 @@ func isSSIDTrusted(ssid string) bool {
}
return false
}

// hasInternetConnection checks if the device has an active internet connection.
func hasInternetConnection() bool {
timeout := 2 * time.Second
conn, err := net.DialTimeout("tcp", "8.8.8.8:53", timeout)
if err != nil {
conn, err := net.DialTimeout("tcp", "1.1.1.1:53", timeout)
if err != nil {
if conn != nil {
if cerr := conn.Close(); cerr != nil {
// Log error if closing connection fails
fmt.Println("Error closing connection:", cerr)
}
}
return false
}
}
if conn != nil {
if cerr := conn.Close(); cerr != nil {
fmt.Println("Error closing connection:", cerr)
}
}
return true
}
5 changes: 4 additions & 1 deletion startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ func isStartupEnabled() bool {

// addStartupShortcut creates a Windows shortcut for autostart.
func addStartupShortcut() {
ole.CoInitialize(0)
if err := ole.CoInitialize(0); err != nil {
fmt.Println("CoInitialize error:", err)
return
}
defer ole.CoUninitialize()

exePath, err := os.Executable()
Expand Down
44 changes: 39 additions & 5 deletions tailscale.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,32 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"syscall"
)

// checkTailscaleExists returns true if the Tailscale binary exists.
func checkTailscaleExists() bool {
_, err := os.Stat(tailscalePath)
return err == nil
// isValidTailscalePath checks if the path is absolute and points to an .exe file in Program Files.
// isValidTailscalePath checks if the path is absolute, points to tailscale.exe in Program Files, and is executable.
func isValidTailscalePath(path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return false
}

// Check if file exists and is executable
info, err := os.Stat(abs)
if err != nil || info.IsDir() {
return false
}

// On Windows, check for .exe extension and that the file is not a directory
return filepath.Ext(abs) == ".exe"
}

// isValidExitNodeName ensures the exit node name is alphanumeric, dash or underscore (no spaces or shell metacharacters).
func isValidExitNodeName(name string) bool {
return regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`).MatchString(name)
}

// getExitNodeName returns the first exit node from config or a default.
Expand All @@ -22,10 +41,20 @@ func getExitNodeName() string {
}

// activateExitNode runs tailscale up with exit node.
// #nosec G204
func activateExitNode() {
if !isValidTailscalePath(tailscalePath) {
fmt.Println("Unsafe tailscalePath, aborting command")
return
}
exitNode := getExitNodeName()
if !isValidExitNodeName(exitNode) {
fmt.Println("Unsafe exit node name, aborting command")
return
}
cmd := exec.Command(tailscalePath,
"up",
"--exit-node="+getExitNodeName(),
"--exit-node="+exitNode,
"--accept-dns=true",
"--shields-up")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
Expand All @@ -37,7 +66,12 @@ func activateExitNode() {
}

// deactivateExitNode disables exit node.
// #nosec G204
func deactivateExitNode() {
if !isValidTailscalePath(tailscalePath) {
fmt.Println("Unsafe tailscalePath, aborting command")
return
}
cmd := exec.Command(tailscalePath,
"up",
"--exit-node=",
Expand Down
28 changes: 24 additions & 4 deletions tray.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package main

import (
"crypto/rand"
"fmt"
"math/rand"
"math/big"
"time"

"github.com/getlantern/systray"
Expand All @@ -12,8 +13,20 @@ 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
var checkInterval = 5 * time.Second

// Use crypto/rand for secure random interval
var updateInterval = getSecureRandomInterval()

// getSecureRandomInterval returns a random duration between 1 and 60 minutes using crypto/rand.
func getSecureRandomInterval() time.Duration {
n, err := rand.Int(rand.Reader, big.NewInt(60))
if err != nil {
// fallback to 15 minutes if crypto/rand fails
return 15 * time.Minute
}
return time.Duration(n.Int64()+1) * time.Minute
}

func autoExitNote() {
mStatus := systray.AddMenuItem("Status: Initializing...", "Current network status")
Expand Down Expand Up @@ -86,6 +99,7 @@ func checkAndApply(mStatus *systray.MenuItem) {
}
ssid, err := getCurrentSSID()
cell := isCellularConnected()
online := hasInternetConnection()

statusText := ""
tooltip := ""
Expand All @@ -103,6 +117,12 @@ func checkAndApply(mStatus *systray.MenuItem) {
tooltip = fmt.Sprintf("Active: %s (unknown network)", getExitNodeName())
icon = iconActive
command = "activated"
case !online:
statusText = "No Internet"
tooltip = fmt.Sprintf("Active: %s (unknown network)", getExitNodeName())
icon = iconActive
command = "activated"
activateExitNode()
case isSSIDTrusted(ssid):
statusText = fmt.Sprintf("Trusted SSID: %s", ssid)
tooltip = fmt.Sprintf("Inactive: trusted network (%s)", ssid)
Expand All @@ -119,7 +139,7 @@ func checkAndApply(mStatus *systray.MenuItem) {
systray.SetIcon(icon)
systray.SetTooltip(tooltip)

if ssid == lastSSID && cell == lastCellular && lastCommand == command {
if ssid == lastSSID && cell == lastCellular && lastCommand == command && online {
return
}
lastSSID = ssid
Expand Down
16 changes: 15 additions & 1 deletion update.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,23 @@ func checkForUpdate(cb func(version, url string)) {
}
}

// isSafeForPowerShell checks for dangerous characters that could break out of the PowerShell string literal.
func isSafeForPowerShell(s string) bool {
// Disallow newlines and backticks, which can break PowerShell string literals
return !strings.ContainsAny(s, "`\r\n")
}

// showWindowsNotification displays a notification on Windows using PowerShell.
// Input is sanitized and validated to prevent command injection.
// #nosec G204
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)))
if !isSafeForPowerShell(title) || !isSafeForPowerShell(message) {
fmt.Println("Unsafe notification input detected, aborting notification")
return
}
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)
Expand Down
Loading