Skip to content

Instantly share code, notes, and snippets.

@abisuq
Created February 6, 2025 07:48
Show Gist options
  • Save abisuq/0f29a816b7ce0f3c6ef3a6264885c2c1 to your computer and use it in GitHub Desktop.
Save abisuq/0f29a816b7ce0f3c6ef3a6264885c2c1 to your computer and use it in GitHub Desktop.
-- Tailscale 相关常量
local TAILSCALE_BIN = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
local COMMANDS = {
STATUS = TAILSCALE_BIN .. " status",
DOWN = TAILSCALE_BIN .. " down",
UP = TAILSCALE_BIN .. " up"
}
-- 选项常量
local CHOICES = {
NONE = "None",
TURN_OFF = "Turn Off Tailscale",
TURN_ON = "Turn On Tailscale"
}
-- 缓存文件路径
local CACHE_FILE = os.getenv("HOME") .. "/.hammerspoon/xxx/tailscale_location_cache.json"
local IP_MAPPING_CACHE_FILE = os.getenv("HOME") .. "/.hammerspoon/xxx/tailscale_ip_mapping_cache.json"
local IP_API_URL = "http://ip-api.com/json/%s?fields=country,regionName,city"
-- 添加 IP 映射缓存相关函数
local function loadIpMappingCache()
local file = io.open(IP_MAPPING_CACHE_FILE, "r")
if not file then return {} end
local content = file:read("*all")
file:close()
return hs.json.decode(content) or {}
end
local function saveIpMappingCache(cache)
local file = io.open(IP_MAPPING_CACHE_FILE, "w")
if not file then return end
file:write(hs.json.encode(cache))
file:close()
end
local function loadCache()
local file = io.open(CACHE_FILE, "r")
if not file then return {} end
local content = file:read("*all")
file:close()
return hs.json.decode(content) or {}
end
local function saveCache(cache)
local file = io.open(CACHE_FILE, "w")
if not file then return end
file:write(hs.json.encode(cache))
file:close()
end
local function getLocationInfo(ip)
if not ip then return nil end
local cache = loadCache()
if cache[ip] then return cache[ip] end
local status, body = hs.http.get(string.format(IP_API_URL, ip))
if status == 200 then
local location = hs.json.decode(body)
if location and location.country then
local info = string.format("%s, %s", location.country, location.city or location.regionName)
cache[ip] = info
saveCache(cache)
return info
end
end
return nil
end
-- 修改 parseExitNode 函数
local function parseExitNode(line)
-- 检查是否为 exit node
if not (line:match("offers exit node") or line:match("exit node")) then
return nil
end
-- 解析节点基本信息
local ip, name, status = line:match("([%d%.]+)%s+([^%s]+)%s+[^%s]+%s+([^%s]+)")
if not (ip and name) then
return nil
end
-- 提取并缓存公网 IP
local public_ip = line:match("direct%s+([%d%.]+):")
if public_ip then
local mapping_cache = loadIpMappingCache()
mapping_cache[ip] = public_ip
saveIpMappingCache(mapping_cache)
end
-- 获取位置信息
local location = getLocationInfo(public_ip)
local isCurrentExitNode = line:match("active; exit node") ~= nil
return {
ip = ip,
name = name,
public_ip = public_ip,
location = location,
display = location and string.format("%s (%s)", name, location) or name,
status = status,
isCurrentExitNode = isCurrentExitNode
}
end
-- 添加获取公网 IP 的辅助函数
local function getPublicIp(internal_ip)
local mapping_cache = loadIpMappingCache()
return mapping_cache[internal_ip]
end
local function executeCommand(cmd, message)
print("Executing:", cmd)
if hs.execute(cmd) then
hs.alert.show(message)
return true
end
hs.alert.show("Failed: " .. message)
return false
end
local function getExitNodes()
local nodes = {}
local handle = io.popen(COMMANDS.STATUS)
if not handle then return nodes end
for line in handle:lines() do
local node = parseExitNode(line)
if node then table.insert(nodes, node) end
end
handle:close()
return nodes
end
local function isTailscaleRunning()
local handle = io.popen(COMMANDS.STATUS)
local result = handle:read("*a")
handle:close()
return result:match("^%d+%.%d+%.%d+%.%d+") ~= nil
end
local function handleExitNodeChoice(choice)
if not choice then return end
if choice.text == CHOICES.TURN_OFF then
executeCommand(COMMANDS.DOWN, "Tailscale disabled")
elseif choice.text == CHOICES.TURN_ON then
executeCommand(COMMANDS.UP, "Tailscale enabled")
elseif choice.text == CHOICES.NONE then
executeCommand(COMMANDS.UP .. " --exit-node=", "Exit node disabled")
else
local node = choice.node
local cmd = string.format("%s --exit-node=%q --exit-node-allow-lan-access", COMMANDS.UP, node.ip)
local message = string.format("Switched to exit node: %s%s",
node.name,
node.location and " (" .. node.location .. ")" or ""
)
executeCommand(cmd, message)
end
end
-- 添加获取当前 exit node 的函数
local function getCurrentExitNode()
local handle = io.popen(COMMANDS.STATUS)
if not handle then return nil end
for line in handle:lines() do
if line:match("active; exit node") then
local ip = line:match("([%d%.]+)%s+")
handle:close()
return ip
end
end
handle:close()
return nil
end
-- 修改 switchExitNode 函数中的节点显示部分
local function switchExitNode()
local choices = {}
if not isTailscaleRunning() then
table.insert(choices, {
text = CHOICES.TURN_ON,
subText = "Start Tailscale service"
})
else
local nodes = getExitNodes()
local currentExitNode = getCurrentExitNode()
for _, node in ipairs(nodes) do
local isActive = node.ip == currentExitNode
table.insert(choices, {
text = string.format("%s%s",
node.display,
isActive and " 🚩" or ""
),
subText = "IP: " .. node.ip,
node = node
})
end
-- 添加特殊选项
table.insert(choices, {
text = CHOICES.NONE,
subText = "Disable exit node"
})
table.insert(choices, {
text = CHOICES.TURN_OFF,
subText = "Turn off Tailscale service"
})
end
hs.chooser.new(handleExitNodeChoice)
:choices(choices)
:show()
end
hs.hotkey.bind({ "cmd", "shift" }, "8", switchExitNode)
return switchExitNode
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment