前言

現代在各種服務的伺服器上輕易可見 CDN 內容提供網路,在媒體檔案的服務部分,CDN 常見的是能夠直接訪問伺服器中的檔案提供給使用者,但在特定的情況下: 付費網路補習班、付費線上課程、需要付費的內容...等來做管理,則需要對 CDN 加入規則。

CDN 是內容提供網路 (或稱反向代理伺服器),用 CDN 來幫自己的伺服器做代理的好處很多,其中就可以擋住 DDoS、減少短時間請求附載,且大多的 CDN 服務商都打著全世界各地都有 CDN 伺服器的名號,加速反應時間。

如果不曉得什麼是 CDN,這張圖說明傳統 DNS 指向自己的伺服器跟加入 CDN 服務的差異:

實驗環境

CDN 服務商

本文章使用的 CDN 服務是 Fastly ,在提供比較大的影片檔案時,Fastly 性能似乎比較好。

CloudFlare 在影片類服務上是當作他們的企業服務,因此不適合做這次的實驗。

Golang 環境

Golang 程式部分只有使用 github.com/gin-gonic/gin 作為外來套件,因此需要先在環境中先安裝:

go get github.com/gin-gonic/gin  

架構說明

本次進行實驗則要把直接存取檔案的架構改為下圖:

在架構中,把主要的服務(Main Server) 和 媒體檔案伺服器 (Content Server) 當成是兩個不同的伺服器, Main Server 會提供一組網址和 Token 給使用者去媒體伺服器取用檔案。

Main Server 是從主要服務產生存取媒體檔案的網址,像是 http://xxx.com/media/46EBFAC2A4B3B2AD4AF488544DD4262DA4B81CCC.mp4?token=1025632623_1C7B5B409712F0103FDF644075BD07F17C3D86F7

Content Server 是媒體的伺服器,它需要把欲存取的檔案,像是 Mozart.mp4 建立一個雜湊(Hash)的路由,像是 /media/46EBFAC2A4B3B2AD4AF488544DD4262DA4B81CCC.mp4

Content 建立雜湊網址來指向一個檔案,是為了可以為該使用者建立屬於它的存取網址,避免每個人用同一個網址就可以存取,那樣就算是路人也可以隨便下載付費內容,但這裡所做的也只有限制取得 Token 的使用者存取該檔案。

若該資料更隱密,則需要另外在做 UAC控制(User Access Control)。

在 CDN 上建立規則

常見在 CDN 服務上的規則定義方式有 (Varnish)VCL 描述檔案、Web Application Firewall...等,Fastly 是使用 Varnish ,因此只要設定 VCL 檔案就可以使用。

先到 Fastly.com 註冊之後,到自己的網域 DNS 設定 CNAME 記錄指到 nonssl.global.fastly.net

接著,在 Fastly 後台的 CONFIGURE 設定中點進去版本(一開始是 Version 1),就會看到 CONFIGURATION 按鈕,要從這裡 Clone 一個設定檔出來。(在 Fastly 裡面的設定檔有做版本控制,必須先 Clone 一個出來才能更改設定,若已經啟用,則必須再 Clone 一個出來)

使用 Clone 複製出來一個設定檔案後,從下方的側欄可以找到 VCL snippets 設定,從這邊進去後用 CREATE SNIPPET 建立一個 VCL 設定檔案。

參考官方的文件: https://docs.fastly.com/guides/tutorials/enabling-url-token-validation ,先選擇把程式碼插入到 recv(vcl_recv) 這個預存程序裡面,然後再把官方程式碼直接貼到 VCL 裡面。

這裡要注意的是,官方的程式碼有條 digest.base64_decode("YOUR%SECRET%KEY%IN%BASE64%HERE") 程式,裡面的字串要放入 base64 編碼過的 key。

設定好後直接儲存再 ACTIVATE 就可以啟用驗證了。

用 Golang 提供存取的網址 Token

在檔案一開始要加入全域變數:

var (  
  secret_key string = base64.StdEncoding.EncodeToString([]byte("TODAYISMYHAPPYDAY&^[email protected]#s"))
)

secret_key 裡面就是原始的 key 字串,會經過 base64 編碼後放進 secret_key。

生產 Token 的函數是:

func generateToken(urlpath string, expTime int64) string {  
    base64data, _ := base64.StdEncoding.DecodeString(secret_key)
    timestamp := strconv.FormatInt(expTime, 10)
    unsigndata := urlpath + timestamp

    //sha1
    h := hmac.New(sha1.New, base64data)
    io.WriteString(h, unsigndata)
    signdata := h.Sum(nil)
    strTypeSignData := hex.EncodeToString(signdata)

    //token format
    token := timestamp + "_" + strTypeSignData
    return token
}

函式會先將訪問的網址路徑 urlpath 跟過期日期 expTime 加在一起,做 sha1 的加密,要注意 Golang 這邊的 Int 要記得用 (strconv.FormatInt) 處理 Int64 型別。

之後測試生產一個 Token:

generateToken("/public/data.mp4", makeExpTime())

func makeExpTime() int64 {  
    t := time.Now().Local().Add(
    time.Hour*time.Duration(0) +
    time.Minute*time.Duration(10) + //after 10 minute
    time.Second*time.Duration(0))
    return t.Unix()
}

makeExpTime 是在現在的時間加上 10 分鐘,變成過期時間,放到 generateToken 函數中。

用 Golang 提供檔案

因為實驗是模擬在同一個檔案中,因此還要再對全域變數增加一個 authorize_table 放在剛剛新增的 secret_key 後面。

var (  
  secret_key string = base64.StdEncoding.EncodeToString([]byte("TODAYISMYHAPPYDAY&^[email protected]#s"))

  authorize_table map[string]string = map[string]string{}
)

authorize_table 會記錄檔案與雜湊之間的連結,若在過期情況下刪除,或被刪除,就無法再用雜湊來訪問檔案。

產生檔案雜湊的同時,就要用網址做 Token ,才會讓整個功能跑起來,模擬的程式如下:

func main() {

    r := gin.Default()

    //Serve Media File, /serveMedia/this_is_hash
    r.GET("/serveMedia/:hash", func(c *gin.Context) {
        filepath := authorize_table[c.Param("hash")]
        c.File(filepath)
    })

    //Show the 10 min exp link with token
    r.GET("/generateMediaUrl", func(c *gin.Context) {
        //simulate hash
        hash := "kfskfoksdokfpsd"

        //add hash with filename to table
        authorize_table[hash] = "./1.mp4"

        //build token
        ru := "/serveMedia/" + hash
        urltoken := generateToken(ru, makeExpTime())
        c.String(200, ru+"?token="+urltoken)
    })

    r.GET("/removeFile", func(c *gin.Context) {
        //simulate hash
        hash := "kfskfoksdokfpsd"

        //delete file in table
        delete(authorize_table, hash)
        c.JSON(200, "good")
    })

    r.Run(":80")

}

/serveMedia/:hash 這個路由就是用來提供檔案的,給一個雜湊值就會對應到一個檔案。

/generateMediaUrl 這個路由是用來測試的,裡面換先建立 authorize_table 跟檔案路徑之間的對應,然後用這個雜湊當作網址,放進 generateToken 函數裡面製作 Token 回傳給使用者。

/removeFile 這個路由是用來移除雜湊與檔案之間的連結,一旦移除就無法再存取檔案。

測試

上面完整的程式放在 Gist 上: https://gist.github.com/hpcslag/3904e246bd183ae91780df86d941a12e,可以直接取用來進行實驗。

測試時,必須要用兩個不同網址測試,因為指向 Fastly 的 CNAME 網址會因為剛剛加入的 URL Token Validation 導致你沒有 Token 所以無法存取。

這時候就要從原始的 IP (或本地)先取得 Token 網址 http://localhost/generateMediaUrl ,再放到 Fastly 代理的網址上測試 https://video.mydomain.com/serveMedia/kfskfoksdokfpsd?token=1512365896_xxxxxxxxxxxxxxxxxxxxxx

我測試的結果是 Fastly 似乎慢了一點,但是讀取過一次就會變快,或許是在測試帳號的情況下導致的,因此你可以回到 Fastly 的儀表板,去把暫存資料清掉 (PURE ALL)。

後記

這篇原本就是因為做線上課程時考慮到影音檔案大量導致原本的伺服器附載過高,因此考慮把影音檔案跟主網站分開,然後在影音檔案伺服器上加入 CDN,但因為有瀏覽權限問題,所以特意考慮這樣的架構設計。

在這篇文章後的 23 天就是畢業典禮了,從入學到畢業的 5 年時間真的很充實,但在畢業的這一年來,因為要準備考試,導致我都比較少寫程式,只能在閒時挑個專案出來做,好讓自己不要忘了寫程式的手感。

最近因為忘記續約舊網址,所以不小心把 vector4.jp 這個網址給丟了,從 Godaddy 買回居然還要付 2 倍的價格贖回來,簡直可怕,所以我就用我在 PSN 上 ID 的俄語諧音 vektop 當作新的網域名稱。