--[[

  ●Office 365 と Windows Update と G Suite インターネットブレイクアウト対応スクリプト
    Office 365 と Windows Update と G Suite の URL を、FQDN フィルターに設定するスクリプトです。
    Office 365 の URL については、定期的に REST API にアクセスしてバージョン情報を取得し、
    バージョンが更新されていた場合は、URL を取得します。
    Windows Update と G Suite の URL については、固定の URL を使用します。
    URL を ip filter コマンドで設定し、フィルター型ルーティングに使用します。
    Office 365 と Windows Update と G Suite 向けの通信とその他の通信との経路を分け、
    Office 365 と Windows Update と G Suite による帯域の圧迫を解消します。

  <説明>
  ・このファイルを EMFS か RTFS か 外部メモリに保存してください。
  ・本項目の config の設定では schedule at コマンドでルーター起動時に Lua スク
    リプトが実行されるように設定しています。
  ・スクリプトを停止するときは terminate lua コマンドを実行してください。
  ・再度、Lua スクリプトを実行する場合は lua コマンドで実行してください。
  ・★マークの付いた設定値は変更が可能です。

  <ノート>
  ・経路と FQDN フィルターの設定追加、または削除に失敗した時に出力する SYSLOG
    レベルを指定することができます。
    SYSLOG のレベルを指定するには、log_level を設定してください。
    debug レベル、notice レベルの SYSLOG を出力するためには、それぞれ以下の設定
    が必要です。
      debug レベル ・・・ syslog debug on
      notice レベル・・・ syslog notice on
  ・本スクリプトファイルを編集する場合、文字コードは必ず Shift-JIS を使用してく
    ださい。

]]


--------------------------##  設定値  ##--------------------------------

-- コマンドパラメーター --
-- 経路
gateway_default = "pp 1"	-- ★ デフォルト経路に使用するインターフェース
gateway_offload = "pp 2"	-- ★ Office 365、Windows Update、G Suiteに使用するインターフェース

-- FQDNフィルターに使用するフィルターIDのプレフィックス
filter_prefix = 2000000

-- Office 365 インターネットブレイクアウトのSYSLOGのプレフィックス
offload_prefix = "[O365 Offload]"

-- サーバーへの問い合わせ間隔(秒)
idle_time = 3600		-- ★

-- ルーター起動時の PPPoE 接続の待機時間(秒)
check_connection_time = 10		-- ★

-- ルーター起動時の PPPoE 接続の確認回数(回)
check_connection_count = 5		-- ★

-- HTTPS リクエスト --
-- ホスト名
office_host = "endpoints.office.com"

-- パス
path_version = "version/worldwide?clientrequestid=%s&Format=CSV"
path_endpoint = "endpoints/worldwide?clientrequestid=%s&Format=CSV&NoIPv6=true"

-- url
url_template = "https://%s/%s"

-- リクエストテーブル
req_tbl = {
	url = "",
	method = "GET"
}

-- Windows Update の接続先URLリスト(テーブル)
urls_wu = {"update.microsoft.com", "*.update.microsoft.com", "download.windowsupdate.com", "*.download.windowsupdate.com", "download.microsoft.com", "*.download.microsoft.com", "windowsupdate.com", "*.windowsupdate.com", "ntservicepack.microsoft.com", "login.live.com", "mp.microsoft.com", "*.mp.microsoft.com", "*.do.dsp.mp.microsoft.com", "*.dl.delivery.mp.microsoft.com", "*.emdl.ws.microsoft.com"}
-- インターネットブレイクアウトしない場合は、下の行のコメント"--"を外す ★
--urls_wu = {}

-- G Suite の接続先URLリスト(テーブル)
urls_gs = {"accounts.google.com", "www.googleapis.com", "oauth2.googleapis.com", "*.googleapis.com", "www.google.com", "apps-apis.google.com", "accounts.youtube.com", "fonts.gstatic.com", "ssl.gstatic.com", "www.gstatic.com", "classroom.googleapis.com", "people.googleapis.com", "sheets.googleapis.com", "slides.googleapis.com", "gsuite.google.com", "mail.google.com", "calendar.google.com", "chat.google.com", "meet.google.com", "drive.google.com", "cloud.google.com", "docs.google.com", "sites.google.com", "developers.google.com", "keep.google.com", "jamboard.google.com", "admin.google.com", "ediscovery.google.com"}
-- インターネットブレイクアウトしない場合は、下の行のコメント"--"を外す ★
--urls_gs = {}

-- 出力する SYSLOG のレベル(info, debug, notice)
log_level = "(SYSLOGレベル)"		-- ★

-- メッセージ --
msg_fail_ver            = "Failed to get version from server"
msg_fail_server_http    = "Failed to data transmission to server, HTTP STATUS: %d"
msg_fail_server_err     = "Failed to data transmission to server, err: %s"
msg_fail_server         = "Failed to data transmission to server"
msg_fail_get_url        = "Failed to get urls"
msg_no_pppoe_connection = "PPPoE not connected"
msg_new_ver             = "New version of service instance endpoints has been detected"
msg_cancel              = "Service instance endpoints checking is canceled"
msg_update              = "Service instance endpoints have been successfully updated"

----------------------##  設定値ここまで  ##----------------------------

------------------------------------------------------------
-- ログメッセージをSYSLOGに出力する関数
-- [引数]
--     msg_fmt ... メッセージフォーマット(書式指定文字列)
--     #2以降  ... 書式指定子に入れる値
-- [戻り値]
--     なし
------------------------------------------------------------
function write_to_log(msg_fmt, ...)
	local log_msg

	log_msg = string.format(msg_fmt, ...)
	log_msg = string.format("%s %s", offload_prefix, log_msg)
	rt.syslog(log_level, log_msg)
end

------------------------------------------------------------
-- UUIDを生成する関数
-- [引数]
--     なし
-- [戻り値]
--     UUID(文字列)
------------------------------------------------------------
function generate_uuid()
	local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"

	math.randomseed(os.time())

	return string.gsub(template, "[xy]", function (c)
		local v = (c == "x") and math.random(0, 0xf) or math.random(8, 0xb)
		return string.format("%x", v)
	end)

end

------------------------------------------------------------
-- Officeサーバーからバージョンを取得する関数
-- [引数]
--     uuid ... UUID(文字列)
-- [戻り値]
--     バージョン(数値)
------------------------------------------------------------
function get_version_from_server(uuid)
	local path, v, cur_ver, res_tbl
	local ptn = "%w+,%w+,%w+%s+%w+,(%w+),%w+"
	local cur_ver = 0

	path = string.format(path_version, uuid)
	req_tbl.url = string.format(url_template, office_host, path)
	res_tbl = rt.httprequest(req_tbl)

	if (res_tbl.rtn1) and (res_tbl.code) and (res_tbl.code == 200) then
		-- HTTPレスポンスを正常に受信できた

		v = res_tbl.body:match(ptn)	-- バージョンを取得
		if (v) then
			v = tonumber(v)		-- 数値に変換
			if (v) then
				cur_ver = v	-- 数値変換に成功した場合、cur_verに保存
			end
		end
		if (cur_ver == 0) then
			write_to_log(msg_fail_ver)
		end
	else
		if (res_tbl.code) then
			write_to_log(msg_fail_server_http, res_tbl.code)
		elseif (res_tbl.err) then
			write_to_log(msg_fail_server_err, res_tbl.err)
		else
			write_to_log(msg_fail_server)
		end
	end

	return cur_ver
end

------------------------------------------------------------
-- Officeサーバーからエンドポイントを取得する関数
-- [引数]
--     uuid ... UUID(文字列)
-- [戻り値]
--     Officeサーバーから受信したレスポンスデータ(文字列) / nil
------------------------------------------------------------
function get_endpoints_from_server(uuid)
	local res_tbl, path, body

	path = string.format(path_endpoint, uuid)
	req_tbl.url = string.format(url_template, office_host, path)
	res_tbl = rt.httprequest(req_tbl)

	if (res_tbl.rtn1) and (res_tbl.code) and (res_tbl.code == 200) then
		-- 送信成功
		if is_transenc_chunked(res_tbl.header) then
			body = res_tbl.body:gsub(/\r\n[0-9a-fA-F]+\r\n|^[0-9a-fA-F]+\r\n/, "")
			return body
		else
			return res_tbl.body
		end
	else
		if (res_tbl.code) then
			write_to_log(msg_fail_server_http, res_tbl.code)
		elseif (res_tbl.err) then
			write_to_log(msg_fail_server_err, res_tbl.err)
		else
			write_to_log(msg_fail_server)
		end
	end

	return nil
end

------------------------------------------------------------
-- CSV形式の1行をテーブルに変換する関数
-- [引数]
--     line ... CSV形式の1行(文字列)
-- [戻り値]
--     テーブルに変換したもの(テーブル)
------------------------------------------------------------
function ParseCSVLine(line)
	local res = {}
	local pos = 1
	local sep = ','
	while (true) do
		local c = string.sub(line,pos,pos)
		if (c == "") then break end
		if (c == '"') then
			-- quoted value (ignore separator within)
			local txt = ""
			repeat
				local startp,endp = string.find(line,'^%b""',pos)
				txt = txt..string.sub(line,startp+1,endp-1)
				pos = endp + 1
				c = string.sub(line,pos,pos)
				if (c == '"') then txt = txt..'"' end
				-- check first char AFTER quoted string, if it is another
				-- quoted string without separator, then append it
				-- this is the way to "escape" the quote char in a quote. example:
				--   value1,"blub""blip""boing",value3  will result in blub"blip"boing  for the middle
			until (c ~= '"')
			table.insert(res,txt)
			assert(c == sep or c == "")
			pos = pos + 1
		else
			-- no quotes used, just look for the first separator
			local startp,endp = string.find(line,sep,pos)
			if (startp) then
				table.insert(res,string.sub(line,pos,startp-1))
				pos = endp + 1
			else
				-- no separator found -> use rest of string and terminate
				table.insert(res,string.sub(line,pos))
				break
			end
		end
	end

	return res
end

------------------------------------------------------------
-- チャンク転送であるか否かを取得する関数
-- [引数]
--     hdr ... Officeサーバーから受信したレスポンスヘッダー(文字列)
-- [戻り値]
--     チャンク転送であるか否か(Boolean型)
------------------------------------------------------------
function is_transenc_chunked(hdr)
	ptn = "Transfer%-Encoding%:%s+chunked"

	if hdr:match(ptn) then
		return true
	end

	return false
end

------------------------------------------------------------
-- レスポンスデータからURLリストを取得する関数
-- [引数]
--     body ... Officeサーバーから受信したレスポンスデータ(文字列)
-- [戻り値]
--     URLのリスト(テーブル)
------------------------------------------------------------
function get_urls(body)
	local lines, line
	local i, v
	local url_num = 0
	local cat_num = 0
	local ip_num = 0
	local urls_list = {}

	if (body ~= nil) then
		lines = { string.split(body, /\v+/) }
		for i, v in ipairs(lines) do
			line = ParseCSVLine(v)
			-- 1行目のパラメーター名から、urls と category の位置を取得
			if (i == 1) then
				for i, v in ipairs(line) do
					if (v == "urls") then
						url_num = i
					elseif (v == "category") then
						cat_num = i
					elseif (v == "ips") then
						ip_num = i
					end
				end
				if (url_num == 0) or (cat_num == 0) or (ip_num == 0) then
					break
				end
			else
				if (line[cat_num] == "Allow") or (line[cat_num] == "Optimize") then
					-- urls を保存
					if (line[url_num] ~= nil) and (string.len(line[url_num]) > 0) then
						table.insert(urls_list, line[url_num])
					elseif (line[ip_num] ~= nil) and (string.len(line[ip_num]) > 0) then
						table.insert(urls_list, line[ip_num])
					end
				end
			end
		end
	end

	return urls_list
end

------------------------------------------------------------
-- URLリストから重複を削除、ソートする関数
-- [引数]
--     urls_list ... URLのリスト重複あり(テーブル)
-- [戻り値]
--     URLのリスト重複なし(テーブル)
------------------------------------------------------------
function del_duplicate_urls(urls_list)
	local url_splited = {}
	local flist = {}
	local flist_temp = {}
	local urls, url
	local k, v
	local str_urls = ""

	for urls in each(urls_list) do
		url_splited = { string.split(urls, ",") }
		for url in each(url_splited) do
			flist_temp[url] = 1
		end
	end

	for k, v in pairs(flist_temp) do
		table.insert(flist, k)
	end

	table.sort(flist)

	return flist
end

------------------------------------------------------------
-- URLのリストを一定の条件で連結する関数
-- [引数]
--     urls_list ... URLのリスト(テーブル)
-- [戻り値]
--     一定の条件で連結したURLのリスト(テーブル)
-- [条件]
--     連結した文字列の長さが指定数を超えたところで、1つの要素とする
------------------------------------------------------------
function concatenate_urls(urls_list)
	local url
	local str_urls = ""
	local flist = {}
	local urls_len = 256 -- 文字列の長さの閾値(0~)

	for url in each(urls_list) do
		if (str_urls == "") then
			str_urls = url
		else
			str_urls = str_urls .. "," .. url
		end

		if (string.len(str_urls) > urls_len) then
			table.insert(flist, str_urls)
			str_urls = ""
		end
	end

	if (str_urls ~= "") then
		table.insert(flist, str_urls)
	end

	return flist
end

------------------------------------------------------------
-- FQDNフィルターを削除する関数
-- [引数]
--     num ... 削除するフィルター数(数値)
-- [戻り値]
--     なし
------------------------------------------------------------
function exec_no_filter_cmd(num)
	local cmd, i

	for i = 0, (num - 1) do
		cmd = string.format("no ip filter %d", filter_prefix + i)
		rtn, str = rt.command(cmd)
		if (rtn == false) then
			if (str) then
				write_to_log(str)
			end
		end
	end

end

------------------------------------------------------------
-- 2つのリストを結合する関数
-- [引数]
--     table_1 ... 1つめのリスト(テーブル)
--     table_2 ... 2つめのリスト(テーブル)
-- [戻り値]
--     結合した新しいリスト(テーブル)
------------------------------------------------------------
function joint_tables(table_1, table_2)
	local table_new = {}

	for v in each(table_1) do
		table.insert(table_new, v)
	end

	for v in each(table_2) do
		table.insert(table_new, v)
	end
	
	return table_new
end

------------------------------------------------------------
-- FQDNフィルターを設定する関数
-- [引数]
--     urls_list ... URLのリスト(テーブル)
-- [戻り値]
--     フィルター番号のリスト(テーブル)
------------------------------------------------------------
function exec_filter_cmd(urls_list)
	local v, cmd, rtn, str
	local i = 0;
	local flist = {}

	for v in each(urls_list) do
		cmd = string.format("ip filter %d pass * %s", filter_prefix + i, v)
		rtn, str = rt.command(cmd)
		if (rtn == false) then
			if (str) then
				write_to_log(str)
			end
			break
		else
			table.insert(flist, tostring(filter_prefix + i))
			i = i + 1;
		end
	end

	return flist
end

------------------------------------------------------------
-- ip routeコマンドを設定する関数
-- [引数]
--     flist ... フィルター番号のリスト(テーブル)
-- [戻り値]
--     なし
------------------------------------------------------------
function exec_ip_route_cmd(flist)
	local cmd, v, rtn, str
	local buf = ""

	if (#flist == 0) then
		return
	end

	for v in each(flist) do
		buf = buf .. " " .. v
	end

	cmd = string.format("ip route default gateway %s filter %s gateway %s", gateway_offload, buf, gateway_default)
	rtn, str = rt.command(cmd)
	if (rtn == false) then
		if (str) then
			write_to_log(str)
		end
	end

end


------------------------------------------------------------
-- メインルーチン                                         --
------------------------------------------------------------

local buf, cur_ver
local urls
local filters = {}
local pre_ver = 0
local err = 0
local count
local cmd_pppoe = "show status "..gateway_default.." | grep \"接続されています\""
local rtn_pppoe
local str_pppoe


-- PPPoE 接続を待つ
count = 0
while (count < check_connection_count) do
	rtn_pppoe, str_pppoe = rt.command(cmd_pppoe, "off")
	if (str_pppoe ~= nil) then
		break
	end
	count = count + 1
	rt.sleep(check_connection_time)
end

-- PPPoE 接続していない場合に、エラーメッセージを出力し、サーバーへの問い合わせ間隔分待機する
if (count >= check_connection_count) then
	write_to_log(msg_no_pppoe_connection)
	rt.sleep(idle_time)
end

-- UUIDを生成
local uuid = generate_uuid()

while (true) do

	-- Officeサーバーからバージョンを取得
	cur_ver = get_version_from_server(uuid)

	-- 保存したバージョンと比較
	-- 新たに取得したバージョンが新しければ、設定を更新
	if (cur_ver > pre_ver) then
		err = 1
		write_to_log(msg_new_ver)

		-- Officeサーバーからエンドポイントを取得
		buf = get_endpoints_from_server(uuid)
		if (buf ~= nil) then
			-- URLリストを取得
			urls = get_urls(buf)

			if (urls ~= nil) then
				-- URLリストから重複を削除
				urls = del_duplicate_urls(urls)

				if (urls ~= nil) then
					-- Windows UpdateとOffice 365のURLのリストを結合する
					urls = joint_tables(urls_wu, urls)

					-- G SuiteのURLのリストを結合する
					urls = joint_tables(urls_gs, urls)

					-- URLのリストをフィルター単位に分割する
					urls = concatenate_urls(urls)

					-- FQDNフィルターを削除
					exec_no_filter_cmd(#filters)

					-- FQDNフィルターを設定
					filters = exec_filter_cmd(urls)

					if (filters ~= nil) then
						-- 静的経路を設定
						exec_ip_route_cmd(filters)

						-- コンフィグを保存
						rt.command("save")

						err = 0
					end
				end
			else
				write_to_log(msg_fail_get_url)
			end
		end
	end

	if (cur_ver == 0) or (err == 1) then
		write_to_log(msg_cancel)
	elseif (cur_ver > pre_ver) then
		pre_ver = cur_ver
		write_to_log(msg_update)
	end

	rt.sleep(idle_time)
end