ぽよメモ

ファッション情報学徒の備忘録.

macOSで任意のWi-Fiへ接続・切断時に任意のシェルスクリプトを実行させる

きっかけ

弊大学では学内ネットワークとして,学生が自分の持つアカウントで認証すれば使用できる認証付き無線LANが用意されています.ありがたいことに「自動プロキシ検出」さえオンにしていれば自動で設定されるため苦労はしませんが,自宅や研究室などプロキシの存在しない環境においてこの設定をしているとアプリケーションの通信に影響が出ることがあります(下記記事参照)

poyo.hatenablog.jp

macOSには「ネットワーク環境」という設定があり,環境に応じて設定を一括で変更できるプロファイル変更機能が備わっているのですが,自動で切り替わるとかそいう頭の良いことをしてくれません.これをもっと簡単に,そして自動でやりたいなと思い最初は自作し始めたのですが良いツールを見つけたのでやめました.

Hammerspoonの紹介

www.hammerspoon.org

Hammerspoonは上記ページにあるとおり,luaを用いて自分でロジックを書いて様々な動作を自動化するためのソフトウェアです.hsというモジュールにhammerspoonによる独自の拡張が沢山含まれており,非常に幅広い動作を実現しています.
luaは簡単な言語ですが色々と癖があるので,慣れていないと引っかかるところがたまにあります.

環境

インストールは以下から可能です.

github.com

使用する機能

WiFi切り替え検知

実はこれは既に公式ページのGetting Startedに書いてあります.
しかしこのサンプルは一つのSSIDについて接続切断を見ているのみで,複数のSSIDについて接続・切断に対応させようと思うと割と面倒です.加えて言うと,新しいSSIDに対応させようとする度にこの~/.hammerspoon/init.luaを編集して追加しないといけないとなると,やがて肥大化してパンクすることが目に見えています.

とはいえ基本は上記ページの hs.wifi.watcherを使用します.

シェルスクリプト実行

hs.execute()というメソッドによりシェルスクリプト実行が可能です.また,launchdなどを用いて実行させる場合などはパスが/usr/bin:/bin:/usr/sbin:/sbinにしか通っていないのですが,先述のメソッドの第二引数にtrueを渡すとどうも~/.bashrcを読み込んでから実行してくれるようです*1

ただしその場合はオーバーヘッドが大きいため,オプションにより制御できるようにしたいと思います.

実装

ちゃんと色々検証したわけではないので自己責任でお願いします.

構成

そこで下記のような構成にしたいと思います

config.json

下記のようにし,~/.hammerspoon/以下に配置します.

{
    "base": {
        "notify": false,
        "shell": "/bin/bash",
        "with_user_env": true
    },
    "networks": [
        {
            "SSID": "ssid1",
            "profile": "proxy",
            "pre_hook": "hoge.sh",
            "post_hook": "fuga.sh"
        },
        {
            "SSID": "ssid2",
            "pre_hook": "poyo.sh",
            "post_hook": "piyo.sh"
        }
    ]
}
  • notify: 通知を有効化するフラグ.今はconfig.iniを読み込むときに通知を出している.デフォルトはtrue
  • shell: シェルスクリプトを実行するシェル.デフォルトは/bin/bash
  • with_user_env: hs.execute()の第二引数に渡す値.ユーザが定義したパスを読み込んだ状態で実行するかどうか.デフォルトはtrue

config.iniに変更があったときに自動で再読み込み

~/.hammerspoon/init.luaに記述していきます.

-- init
HOME = os.getenv("HOME")
CONFIG_HOME = HOME .. "/.hammerspoon/"
CONFIG_FILE = CONFIG_HOME .. "config.json"

-- key
NETWORKS_KEY = "networks"
SHELL_KEY = "shell"
BASE_CONFIG_KEY = "base"
NOTIFY_KEY = "notify"
USER_ENV_KEY = "with_user_env"
SSID_KEY = "SSID"
PROFILE_KEY = "profile"
PRE_HOOK_KEY = "pre_hook"
POST_HOOK_KEY = "post_hook"

-- default settings
NOTIFY = true
SHELL = "/bin/bash"
USER_ENV = true
DEFAULT_NW_PROFILE = "Automatic"

function hasKey(tbl, key)
    for k, v in pairs (tbl) do
        if k == key then 
            return true 
        end
    end
    return false
end

function readConfig(files)
    -- jsonファイルを開いて一行ずつ読み出し,decode
    local fp = io.open(files[1], "r")
    local json = fp:read("*a")
    fp:close()
    local config = hs.json.decode(json)
    if hasKey(config, BASE_CONFIG_KEY) then
        for k, v in pairs(config[BASE_CONFIG_KEY]) do
            hs.settings.set(k, v)
        end
    end
    -- Default valueをセット
    if not hasKey(config, NOTIFY_KEY) then
        hs.settings.set(NOTIFY_KEY, NOTIFY)
    end
    if not hasKey(config, SHELL_KEY) then
        hs.settings.set(SHELL_KEY, SHELL)
    end
    if not hasKey(config, USER_ENV_KEY) then
        hs.settings.set(USER_ENV_KEY, USER_ENV)
    end
    -- Networkプロファイルをロード
    if hasKey(config, NETWORKS_KEY) then
        hs.settings.set(NETWORKS_KEY, config[NETWORKS_KEY])
    end
    if hs.settings.get(NOTIFY_KEY) then
        local notify_msg = string.format("display notification \"Reloaded\" with title \"WLAN Profiles\"")
        hs.osascript.applescript(notify_msg)
    end
end

-- exec
readConfig({CONFIG_FILE})
hs.pathwatcher.new(CONFIG_FILE, readConfig):start()

Hammerspoonではhs.settingsというキーバリューストア的なものを提供していたのでこれを使えと言うことなのかと,動的なデータのほとんどはこれを介して出し入れしています*2

ネットワーク環境切り替え

これはシェルスクリプトnw_profile_changer.sh)を置いておくことにします.

こちらからアイデアを頂きました.

qiita.com

currentlocation=`networksetup -getcurrentlocation`

if test $currentlocation = $1; then
    return
fi
scselect `scselect | grep ${1} | cut -b 4-40`

これを~/.hammerspoon/Scripts以下に置いておきます.

WiFi切り替え時に実行

SCRIPTS_PATH = CONFIG_HOME .. "Scripts/"
PROFILE_CHANGE_SCRIPT = SCRIPTS_PATH .. "nw_profile_changer.sh"

-- tmp
prevSSID = hs.wifi.currentNetwork()

-- シェルスクリプトを実行する関数
function execHook(path, profile)
    if path then
        local script = SCRIPTS_PATH .. path
        local shell = hs.settings.get(SHELL_KEY)
        local with_user_env = hs.settings.get(USER_ENV_KEY) 
        hs.execute(shell .. " " .. script, with_user_env)
    end
end

-- ネットワーク環境を切り替える関数
function loadNwProfile(profile_name)
    local command = nil
    local shell = hs.settings.get(SHELL_KEY)
    local with_user_env = hs.settings.get(USER_ENV_KEY)  
    if profile_name then
        command = shell .. " " .. PROFILE_CHANGE_SCRIPT .. " " .. profile_name
    else
        command = shell .. " " .. PROFILE_CHANGE_SCRIPT .. " " .. DEFAULT_NW_PROFILE
    end
    hs.execute(command, with_user_env)
end

function nwProfileChanger(ssid, profile, connection)
    if connection then
        -- 接続時にプロファイルを変更
        loadNwProfile(profile[PROFILE_KEY])
        if hasKey(profile, PRE_HOOK_KEY) then
            execHook(profile[PRE_HOOK_KEY])
        end
    else
        if hasKey(profile, POST_HOOK_KEY) then
            execHook(profile[POST_HOOK_KEY])
        end
    end
end

function ssidChangedCallback(watcher, msg, interface)
    local newSSID = hs.wifi.currentNetwork()
    if prevSSID ~= newSSID then
        for i, nw in ipairs(hs.settings.get(NETWORKS_KEY)) do
            if not prevSSID and newSSID and nw[SSID_KEY] == newSSID then
                -- 接続時
                nwProfileChanger(newSSID, nw, true)
                break
            elseif prevSSID and not newSSID and nw[SSID_KEY] == prevSSID then
                -- 切断時
                nwProfileChanger(prevSSID, nw, false)
                break
            end 
        end
    end
    prevSSID = newSSID
end

-- SSID変更を監視
hs.wifi.watcher.new(ssidChangedCallback):start()

解説は省きますが,SSID変更を検知してそのSSIDに合致する設定が書かれていた場合,ネットワーク環境変更およびシェルスクリプト実行を行います.

全体は下記にあります.

任意のWiFi接続・切断時に任意のシェルスクリプトを実行するHammerspoonの設定 · GitHub

Hammerspoonをリロード

メニューバーまたはコンソールからReload Configします.

これで準備は完了です!

応用

研究室のWiFiに接続する度に論文提出までの期限を通知

このためだけにGoで超簡単に書きました.

github.com

下記のようなシェルスクリプトを用意し,~/.hammerspoon/when_connect_lab.shとして配置します.

limit=`go-sotsuron-counter`
osascript -e 'display notification "'"${limit}です"'" with title "Welcome to Laboratory"'

config.jsonに記述

{
    "base": {
        "notify": false,
        "shell": "/bin/bash",
        "with_user_env": true
    },
    "networks": [
        {
            "SSID": "ラボのSSID",
            "pre_hook": "when_connect_lab.sh"
        }
    ]
}

はい!こうなります!

f:id:pudding_info:20180116165550p:plain

思わず画面をたたき割らないように注意しましょう.

gitのプロキシ設定を行う

こういう用途が本命です. プロキシのオンオフスクリプトをそれぞれ書きます.

~/.hammerspoon/proxy_on.sh

# git
git config --global http.proxy http://hoge.com:8080
git config --global https.proxy https://hoge.com:8080

# その他
launchctl setenv HTTP_PROXY http://hoge.com:8080
launchctl setenv http_proxy http://hoge.com:8080
launchctl setenv HTTPS_PROXY https://hoge.com:8080
launchctl setenv https_proxy https://hoge.com:8080
launchctl setenv ALL_PROXY http://hoge.com:8080
launchctl setenv all_proxy http://hoge.com:8080

~/.hammerspoon/proxy_off.sh

[2018/1/30 追記] [http][https]セクションが.gitconfigに大量に追記されてしまう問題を修正しました

# git
git config --global --unset http.proxy
git config --global --unset https.proxy
git config --global --remove-section http
git config --global --remove-section https

# その他
launchctl unsetenv HTTP_PROXY
launchctl unsetenv http_proxy
launchctl unsetenv HTTPS_PROXY
launchctl unsetenv https_proxy
launchctl unsetenv ALL_PROXY
launchctl unsetenv all_proxy

config.jsonに記述

{
    "base": {
        "notify": false,
        "shell": "/bin/bash",
        "with_user_env": true
    },
    "networks": [
        {
            "SSID": "ProxyありのSSID",
            "profile": "プロキシ設定したネットワーク環境名", 
            "pre_hook": "proxy_on.sh",
            "post_hook": "proxy_off.sh"
        }
    ]
}

exportではなくlaunchctl setenvするのがミソです.
適当なWiFiで設定してみて実際に動くか確かめると良いと思います.

デバッグ目的だけならosascript -e 'display notification "hoge" with title "test"'とか書くと実行された場合に通知が出て良いです.

課題

  • 突貫で書いたので雑
  • たまにjson読み込みでエラーを吐く(init.lua再読込で直る).原因不明.
2018-01-16 16:53:19: ********
2018-01-16 16:53:19: 16:53:19 ERROR:   LuaSkin: hs.pathwatcher callback error: The data couldn窶冲 be read because it isn窶冲 in the correct format.
stack traceback:
    [C]: in function 'hs.json.internal.decode'
    /Users/pudding/.hammerspoon/init.lua:42: in function 'readConfig'
2018-01-16 16:53:19: ********

まとめ

自動で設定できるとはいえ,例えばシェルは開き直さないと環境変数が反映されないとかそういうのがあるので,移動時にちゃんと後始末しない(ウィンドウ開けたら開けっぱなし)な人は気をつけないとつらくなります.

…卒論書きます.

*1:zshなどに関しては未検証です

*2:luaではlocalの指定が無い限りグローバル変数になるという特性があるため,複雑化してきたときに思わぬ働きをしてしまわないよう,こうした機構があると便利なのかも知れません.