2017年1月24日 星期二

LinkIt 7688 Web CGI 入門(2)

上一篇 login 範例把 id & password 寫死在 CGI script 裡顯然不靠譜。本篇公開如何把 web form 傳送給 CGI script 的 id & password 傳遞給 7688 完成一個登錄的動作。

rpc_demo.html

MTK 提供的 rpc_demo.html 內有一個 rpc_login()
function rpc_login(user, pass)
{
 var login = { "jsonrpc": "2.0", "id": id++, "method": "call",
  "params": [ "00000000000000000000000000000000","session","login",{
   "username":user,"password":pass,
   }
  ]};

 $("#Session").val("");
 $("#fwSession").val("");
 session = "";
 do_ajax(login, function(reply) {
        //......
 });
}
從 PHP 轉過來的老人大概跟我一樣一頭霧水。因為傳統的作法是:
  1. login > 輸入 id & password
  2. login 成功,web server 以 cookie (Set-Cookie: session id=...) 傳回 session id,並且通知 Browser 自動轉址(Location: config.cgi)到 config.cgi
  3. config.cgi 檢查由 Browser cookie 傳入的 session id 是否合法,決定是否要顯示頁面或是強迫 Browser 退回 login 頁面。
因此 MTK 的範例比較適合新一代 Single Page Application(SPA),要走傳統路線實在有些窒礙難行(至少以筆者的經驗來說)。

BUT, OpenWrt 又走傳統 CGI 路線,MTK 你搞的我好亂啊 2#$^&@#!@...

但對於有專案壓力在身,或者只是要做個簡單設定頁面的工程師來說,去學 React 不但沒需要而且緩不濟急(結果發現還要學 jsx, webpack 一堆奇門遁甲的玩意) 。本篇就來公開在 7688 上如何實做傳統 session id & cookie。

ubus

回顧前面的 rpc_login(),可看到 function 後半段呼叫 do_ajax() 進行登錄,do_ajax() 如下:
function do_ajax(data, success) {
 $.ajax({
  url: "/ubus",
  type: "post",
  data: JSON.stringify(data),
  success: function(reply) {
   success(reply);
  },
 });
}
從 url: "/ubus" 可以看出 login 時把 id 與 password 傳給了 ubus。ubus 全名為 OpenWrt micro bus architecture,簡單來說提供了應用程式與 daemons 間的通訊界面,個人覺得可以看成 OpenWrt 版的 D-bus。事實上他與 D-bus 相同,也是採用 UNIX Domain socket 作為底層通訊界面。


ubus 關聯到哪些服務?


可以看到 7688 幾乎所有重要設定都跟 ubus 有關連,OpenWrt 提供了幾個界面存取 ubus,這邊只列幾個跟我們比較有關的:
在進入 Lua module for ubus 前先用 Command-line ubus tool 試試水溫。我們要先嘗試的當然是我們最在乎的 login,輸入以下指令(受限於 blogspot 版面,指令太長以「\」斷行):

ubus call session login \
'{"username":"root","password":"123456","timeout":1000}'

輸出畫面:


以 command-line ubus tool 呼叫 ubus 時傳入的參數必須以 JSON 包裝。上圖 ubus 回傳的資訊中最重要的就是 ubus_rpc_session,無論使用 Lua or AJAX 都是透過把鑰匙與 ubus 溝通,這把鑰匙由 timeout 參數決定時效(上圖為 1000 秒)。

回顧 rpc_login(),您會發現他做了兩次 RPC:
function rpc_login(user, pass)
{
    //......
 session = "";
 do_ajax(login, function(reply) { //step 1
  if (!reply.result || reply.result[0] != 0) {
   alert("failed to login");
   return;
  }
        //...
        //step 2
  session = reply.result[1].ubus_rpc_session;
  var grant = { "jsonrpc": "2.0", "id": id++, "method": "call",
    "params": [ session, "session", "grant",
     { "scope": "uci", "objects": [ [ "*", "read" ], [ "*", "write" ] ] }
    ]};
  do_ajax(grant, function(reply) {
   if (!reply.result || reply.result[0] != 0) {
    alert("failed to grant object permissions");
    return;
   }
   //load information...
  });
 });
}

OpenWrt 官網對這兩步驟的解釋是:
  1. Authenticate with rpcd and create a new session with access rights as specified in the ACLs
  2. Within the session identified by sid grant access to all specified procedures func in the namespace path listed in the objects array.
步驟 2. 就是要求對 uci 的完整存取權,像是 MTK 有一篇教學就是使用 uci 修改 7688 AP/station mode。

同樣以 command line 實驗看看,輸入以下指令:

ubus call session grant \
'{"ubus_rpc_session":"cda2507...", \
"scope":"uci", "objects": [ [ "*", "read" ], [ "*", "write" ] ]}'

輸出畫面:


沒有回傳任何東西,不過有些 sesssion procedure 這樣就算成功了。使用指令 ubus call session list 列出所有 session:


官方文件對照,我們成功了!


接下來看看如何把這兩步驟放到 Lua CGI script 中。

Lua module for ubus

把 rpc_login() 改以 Lua 實做,如下方源碼所示:

Line 5-8. 與 ubusd (ubus daemon) 建立連線

Line 11-15. 以 Line 5. 取得的 connection object 進行 rpc_login() step 1,「:」是 Lua syntax suger,等同於 ubus_conn.login(ubus_conn, ......)。不必煩惱 JSON 在 Lua 中如何表示,module 會自動把 Lua table 轉成 JSON。

Line 18-23. 進行 rpc_login() step 2.
require "ubus"

function ubus_login(id, passw)
    -- connect ubus server
    local ubus_conn, errcode = ubus.connect(nil, 1000)
    if errcode then
        return nil, 500, errcode
    end
    
    -- login ubus server
    local login_ret, errcode = ubus_conn:call("session", "login", { username = id, password = passw, timeout=300})
    if errcode then
        ubus_conn:close()
        return nil, 401, errcode
    end
    
    -- grant read/write permission
    local grant_param = {ubus_rpc_session = login_ret.ubus_rpc_session, scope = "uci", objects={{"*", "read"},{"*", "write"}}}
    local grant_ret, errcode = ubus_conn:call("session", "grant", grant_param)
    if errcode then
        ubus_conn:close()
        return nil, 500, errcode
    end
    
    ubus_conn:close()
    return login_ret.ubus_rpc_session

end
前篇文末的程式修改一下,我們就得到了傳統 web form login。其中 Line 8 就是傳統 PHP 中把 new session id 傳回給 browser 的方式。

下面程式 Line 13 相信熟習 PHP 的朋友看得出用途,就是如果 Browser 沒有傳過來 id&password 但傳來的 session id 若是合法則跳入預定頁面。Line 14 get_cookie() 與 Line 15 check_session() 馬上就會解釋。
local env = nixio.getenv()
local post_data = get_post_data(env)    
if post_data.username and post_data.password then
    local session, status, errcode = ubus_login(post_data.username, post_data.password)
    if session then
        io.write("Status: " .. "302" .. " " .. statusmsg[302] .. "\r\n")
        io.write("Location: rpc_demo.html\r\n")
        io.write("Set-Cookie: " .. "session=" .. session .. "; path=/\r\n\r\n") 
    else    
        io.write("Content-Type: text/html\r\n\r\n")
        io.write(login_form)
    end
elseif env.HTTP_COOKIE and env.HTTP_COOKIE:len() > 0 then
    local session = get_cookie(env.HTTP_COOKIE, "session")
    if check_session(session) then
        io.write("Status: " .. "302" .. " " .. statusmsg[302] .. "\r\n")
        io.write("Location: rpc_demo.html\r\n\r\n")
    else
        io.write("Content-Type: text/html\r\n\r\n")
        io.write(login_form)
    end
else
    io.write("Content-Type: text/html\r\n\r\n")
    io.write(login_form)
end
get_cookie() 也是從 luci 抄來的
function get_cookie(cookie, name)
  local c = string.gsub(";" .. cookie .. ";", "%s*;%s*", ";")
  local p = ";" .. name .. "=(.-);"
  local i, j, value = c:find(p)
  return value and urldecode(value)
end
至於 check_session() 則是筆者自創。session list '{...}' 代入 sid 時會傳回該 sid 代表的 session 資訊。我們可以用來判斷此 session 是否合法,Line 19-26 檢查 ACLs 是否存在,而且有無 grant 成功後多出的欄位(rpc_login() step 2)。
function check_session(session)
    if session == nil or type(session) ~= "string" or session:len() == 0 then
        return false
    end
    
    local conn = ubus.connect(nil, 1000)
    if not conn then
        return false
    end
    
    local list, errcode = conn:call("session", "list", { ubus_rpc_session = session})
    if errcode then
        conn:close()
        return false, errcode
    end       
    
    conn:close()
    -- list.acls.uci["*"] ~= nil ?
    if list ~= nil and 
       list.acls ~= nil and 
       list.acls.uci ~= nil and
       list.acls.uci["*"] ~= nil then       
        return true
    else
        return false
    end
end

rpc_demo.html

把 rpc_demo.html 做點小修改來確認實驗是否成功,這裡我們需要 js-cookie 這個 JavaScript Library 協助我們取出 Browser cookie(Line 6)。
$( document ).ready(function() {
 $("#BtnLogin").click(function() { rpc_login("root", $("#Password").val()); });
 $("#BtnLogout").click(function() { rpc_logout(); });
 $("#BtnMode").click(function() { rpc_wifi_mode($("#Mode").val()); });
    //......
    session = Cookies.get("session"); //js-cookie
    $("#Session").val(session);
    $("#fwSession").val(session);
    rpc_board_load();
    rpc_system_load();
    rpc_netstate_load("wan");
    //......
});
測試結果:


到這裡為止相信大家已經有能力修改出自己需要的 CGI script,然後加上 bootstrap 就能弄的很像一回事了。

筆者也看過另外一種作法是乾脆修改 OpenWrt luci,在其中增加自己需要的選項。這兩種都見仁見智,也許你不想讓 user 看到太多東西...

範例下載

沒有留言:

張貼留言