前幾天看到朋友寫如何用Charles Proxy來debug REST API, 突然就想到了這個問題, Charles Proxy是一個不錯的工具, 我自己也蠻常用的 , 除了可以用來debug HTTP以外, 其實HTTPS也可以(參照SSL proxying) , 只要在手機上安裝好它的SSL certificate即可

不過這不是這篇要講的重點, 這種debugging的方式原理即是用Proxy作為一個中間人來紀錄HTTP/HTTPS的傳輸內容 , 這也帶來一個問題, 也就是你的資料是可以這樣簡單的暴露出去, 這種即是所謂的MITM (Man in the middle) 中間人攻擊

graph LR; Client-->Proxy[Proxy - Middle man]; Proxy[Proxy - Middle man]-->Client; Proxy[Proxy - Middle man]-->Server; Server-->Proxy[Proxy - Middle man];

一般來說, 用了SSL/HTTPS後, 也不是那麼容易安插一個中間人的 ,像Charles Proxy這樣的東西, 還是需要使用者自己去手機上的安全性設定裝信任憑證(trusted certificate), 一般常見的問題反而來自於開發者本身 , 很多人常以為走了HTTPS就安全了, 卻常常為了debug方便, 或是省錢用Self-signed certificate 而去覆寫了系統預設的 X509TrustManager, 以至於整個檢查機制被跳過, 真的漏洞都來自於人的惰性, 更多資訊可以參考:

  1. 窃听风暴: Android平台https嗅探劫持漏洞
  2. Android安全之Https中间人攻击漏洞
  3. Android HTTPS中间人劫持漏洞浅析
  4. Android客户端安全 -> HTTPS敏感数据劫持漏洞
  5. Android App 安全的HTTPS 通信
  6. 手機應用程式開發上被忽略的 SSL 處理
  7. How To: Use mitmproxy to read and modify HTTPS traffic
  8. INTERCEPTING ANDROID SSL / HTTPS TRAFFIC

該避免去寫的, 就該避掉, 以免產生不必要的漏洞, 但不過也有可能碰到使用者的手機被(有意/無意)安裝了攻擊者的憑證到系統的信任憑證中, 那麼碰到這種, 應用程式本身該如何保護自己?

這邊有兩個方式可以採用 -

  1. certificate pinning
  2. 在程式中寫死certificate

以下的範例以Android最紅, 常被使用的Retrofit為範例(雖說是Retrofit, 但其實是在okhttp動的手腳)

Certificate Pinning

或叫HTTP Public Key Pinning (HPKP), 簡單的說, 就是在程式內綁定Certificate的public key, 如果今天中間人存在, certificate不同了, 連接就不會成功

先來看範例

OkHttpClient client = new OkHttpClient.Builder()
        .certificatePinner(new CertificatePinner.Builder().add("cdn.rawgit.com", "sha256/p0962nIqD0mv1APQ1mgmRiuwrhZXBuj+t6dey/Adk0U=").build())
        .build();
Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("https://cdn.rawgit.com/julianshen/cpblschedule/")
        .addConverterFactory(GsonConverterFactory.create())
        .client(client)
        .build();

這邊所動的手腳(跟Retrofit沒啥鳥關係)是在OkHttpClient上加上一個CertificatePinner, 利用了certificate pinner加上了一個host name跟public key的對應, 以這例子來說: “sha256/p0962nIqD0mv1APQ1mgmRiuwrhZXBuj+t6dey/Adk0U=” 這個public key對應的是 “cdn.rawgit.com” , 因此碰到"cdn.rawgit.com"來的url, 會檢查public key是否符合, 這邊public key的參數字串是要以"sha1/“或"sha256/“開始的, 依使用的演算法而不同

但又要怎取得這串"天書"呢?

很簡單, 首先確認你的電腦裡面有沒安裝openssl , 有的話就用以下的指令:

openssl s_client -connect cdn.rawgit.com:443 -servername cdn.rawgit.com | openssl x509 -pubkey -noout | openssl rsa -pubin -outform der | openssl
dgst -sha256 -binary | openssl enc -base64

最後產生的內容如下

depth=2 /C=GB/ST=Greater Manchester/L=Salford/O=COMODO CA Limited/CN=COMODO RSA Certification Authority
verify error:num=20:unable to get local issuer certificate
verify return:0
writing RSA key
p0962nIqD0mv1APQ1mgmRiuwrhZXBuj+t6dey/Adk0U=

所以我們要填的參數就是 “sha256/p0962nIqD0mv1APQ1mgmRiuwrhZXBuj+t6dey/Adk0U=”

關於HPKP的openssl相關的指令可以參考Public Key Pinning

如果你用Charles Proxy當中間人來測試這段程式的話, 你會得到下面這樣的exception

javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
                                            Peer certificate chain:
                                            sha256/ENlqFHtfARof3AK50Hbc1sj47M5hWhc5kQ5Z2vyfxq4=: CN=rawgit.com,OU=PositiveSSL Multi-Domain,OU=Domain Control Validated
                                            sha256/mXmLzo8k5ANwi11PlLSW/b4OC/Anjw1OeACDyZxD/WM=: C=NZ,ST=Auckland,L=Auckland,O=XK72 Ltd,OU=http://charlesproxy.com/ssl,CN=Charles Proxy Custom Root Certificate (built on Jlnmbp-retina.local\, 11 十二月 2015)
                                            Pinned certificates for cdn.rawgit.com:
                                            sha256/p0962nIqD0mv1APQ1mgmRiuwrhZXBuj+t6dey/Adk0U=

在程式中寫死certificate

這方法聽起來暴力了一點, 但原理其實跟前一個沒啥太大差別, 只是一個寫死public key一個寫死certificate

一樣先來看個範例:

String cert = ""
        +"-----BEGIN CERTIFICATE-----\n"
        +"MIIFdjCCBF6gAwIBAgIRAPbTBHdHHseM4hArA7n6uREwDQYJKoZIhvcNAQELBQAw\n"
        +"gZAxCzAJBgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAO\n"
        +"BgNVBAcTB1NhbGZvcmQxGjAYBgNVBAoTEUNPTU9ETyBDQSBMaW1pdGVkMTYwNAYD\n"
        +"VQQDEy1DT01PRE8gUlNBIERvbWFpbiBWYWxpZGF0aW9uIFNlY3VyZSBTZXJ2ZXIg\n"
        +"Q0EwHhcNMTYwMTAxMDAwMDAwWhcNMTcwMTEzMjM1OTU5WjBbMSEwHwYDVQQLExhE\n"
        +"b21haW4gQ29udHJvbCBWYWxpZGF0ZWQxITAfBgNVBAsTGFBvc2l0aXZlU1NMIE11\n"
        +"bHRpLURvbWFpbjETMBEGA1UEAxMKcmF3Z2l0LmNvbTCCASIwDQYJKoZIhvcNAQEB\n"
        +"BQADggEPADCCAQoCggEBALD2sUNSYp2R+mSEMF2qKvMS780qTkltvqP4rwEGKOLV\n"
        +"rR5QQWo8vzSlgZvxVsguRHi0pPBtVAH794L43tD+IuyQlDlNU2qc1aMqBkn3S2wN\n"
        +"Way8BS9w80pgeFnObZiFJtPI9pdwcB72Bgq8Nlc25oVrDVWr/Q8nLIKS/9FkNs+C\n"
        +"MPU00vGFZSFbR7s15ORj9+qPCskWZcHpQ+m9EKmZD3IVKj3QQyBD17cBoVkYoIpj\n"
        +"I8/r+NfVfKetlsB7Pcv8P3yLFVFC4+PGrnW9TLZK9aRtbDTvjElXwCQIPK5B2fKw\n"
        +"SJK26IJPDX0r1JkeuR53Afr509iEx3xAHcRz/kR5uo8CAwEAAaOCAf0wggH5MB8G\n"
        +"A1UdIwQYMBaAFJCvajqUWgvYkOoSVnPfQ7Q6KNrnMB0GA1UdDgQWBBSI83Kqafo7\n"
        +"kg9cmyT1vnceH4fAUjAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAdBgNV\n"
        +"HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwTwYDVR0gBEgwRjA6BgsrBgEEAbIx\n"
        +"AQICBzArMCkGCCsGAQUFBwIBFh1odHRwczovL3NlY3VyZS5jb21vZG8uY29tL0NQ\n"
        +"UzAIBgZngQwBAgEwVAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5jb21vZG9j\n"
        +"YS5jb20vQ09NT0RPUlNBRG9tYWluVmFsaWRhdGlvblNlY3VyZVNlcnZlckNBLmNy\n"
        +"bDCBhQYIKwYBBQUHAQEEeTB3ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LmNvbW9k\n"
        +"b2NhLmNvbS9DT01PRE9SU0FEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0Eu\n"
        +"Y3J0MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wSwYDVR0R\n"
        +"BEQwQoIKcmF3Z2l0LmNvbYIVY2RuLW9yaWdpbi5yYXdnaXQuY29tgg5jZG4ucmF3\n"
        +"Z2l0LmNvbYINcmF3Z2l0aHViLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEASx3e6y9e\n"
        +"9F67N5NIDausDHDL+/fz6uj2DDNJdaQvALAYDV8hKpz7+QGotlQfI042U2i83J7m\n"
        +"DGZiKDpzaXa7IFfGiq6PFPUjntElHoU2E4vRb5LApg1sJ5YueYmRd3X7x0/jPYg6\n"
        +"TiTtHyXOnlMuqL2FUYJM0BP7cMfOppvUF0R2zHUVA0rVHuLrSStg8bMg8aVUIkKg\n"
        +"n3NS+eg4ofo95jaMRVykhLnylkYBk9dWBM6B19Yw7LgQd94MZSa+Xix4HxeFgvAM\n"
        +"BF+oeDMaY7mEN4Xm5hnrIS1FElRDq/ckpDV8JVpR4SQqB+tzSZPPIiep8EvgRDzd\n"
        +"2EkL187FF3MQ+w==\n"
        +"-----END CERTIFICATE-----\n";

X509TrustManager trustManager = null;
SSLSocketFactory sslSocketFactory = null;

try {
    CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
    Collection<? extends Certificate> certificates = certificateFactory.generateCertificates(new Buffer().writeUtf8(cert).inputStream());
    if (certificates.isEmpty()) {
        throw new IllegalArgumentException("expected non-empty set of trusted certificates");
    } else {
        char[] password = "password".toCharArray(); // Any password will work.
        KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
        keyStore.load(null, password);

        int index = 0;
        for (Certificate certificate : certificates) {
            String certificateAlias = Integer.toString(index++);
            keyStore.setCertificateEntry(certificateAlias, certificate);
        }

        TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        trustManagerFactory.init(keyStore);

        TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
        if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
            throw new IllegalStateException("Unexpected default trust managers:"
                    + Arrays.toString(trustManagers));
        }
        trustManager = (X509TrustManager) trustManagers[0];

        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, new TrustManager[] { trustManager }, null);
        sslSocketFactory = sslContext.getSocketFactory();
    }
} catch (CertificateException e) {
    e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
    e.printStackTrace();
} catch (KeyStoreException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
} catch (KeyManagementException e) {
    e.printStackTrace();
}

OkHttpClient client = new OkHttpClient.Builder()
        .sslSocketFactory(sslSocketFactory, trustManager)
        .build();

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("https://cdn.rawgit.com/julianshen/cpblschedule/")
        .addConverterFactory(GsonConverterFactory.create())
        .client(client)
        .build();

看到這麼長的東西大概就不會想看了吧?(老實說我也寫的很懶, 這邊要特別提到有用到一個Class叫做Buffer,這是來自於okio, 蠻方便的東西), 跟前一個不同的地方在於, 前一個利用了CertificatePinner, 而這一個則是改了sslSocketFactory的TrustManager(喂!!!前面不是隱約有提到這樣不太好?!), 這個TrustManager全部也只信任這一個certifcate, 如果碰到其他的, 就會發生以下的exception:

javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
        at com.android.org.conscrypt.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:328)

那, 上面那一大坨憑證資料是怎樣取得的? 方法其實差不多:

openssl s_client -connect cdn.rawgit.com:443 -showcerts

會得到這樣的結果:

CONNECTED(00000003)
depth=2 /C=GB/ST=Greater Manchester/L=Salford/O=COMODO CA Limited/CN=COMODO RSA Certification Authority
verify error:num=20:unable to get local issuer certificate
verify return:0
---
Certificate chain
 0 s:/OU=Domain Control Validated/OU=PositiveSSL Multi-Domain/CN=rawgit.com
   i:/C=GB/ST=Greater Manchester/L=Salford/O=COMODO CA Limited/CN=COMODO RSA Domain Validation Secure Server CA
 1 s:/OU=Domain Control Validated/OU=PositiveSSL Multi-Domain/CN=rawgit.com
   i:/C=GB/ST=Greater Manchester/L=Salford/O=COMODO CA Limited/CN=COMODO RSA Domain Validation Secure Server CA
 2 s:/C=GB/ST=Greater Manchester/L=Salford/O=COMODO CA Limited/CN=COMODO RSA Domain Validation Secure Server CA
   i:/C=GB/ST=Greater Manchester/L=Salford/O=COMODO CA Limited/CN=COMODO RSA Certification Authority
 3 s:/C=GB/ST=Greater Manchester/L=Salford/O=COMODO CA Limited/CN=COMODO RSA Certification Authority
   i:/C=SE/O=AddTrust AB/OU=AddTrust External TTP Network/CN=AddTrust External CA Root
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIFdjCCBF6gAwIBAgIRAPbTBHdHHseM4hArA7n6uREwDQYJKoZIhvcNAQELBQAw
gZAxCzAJBgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAO
BgNVBAcTB1NhbGZvcmQxGjAYBgNVBAoTEUNPTU9ETyBDQSBMaW1pdGVkMTYwNAYD
VQQDEy1DT01PRE8gUlNBIERvbWFpbiBWYWxpZGF0aW9uIFNlY3VyZSBTZXJ2ZXIg
Q0EwHhcNMTYwMTAxMDAwMDAwWhcNMTcwMTEzMjM1OTU5WjBbMSEwHwYDVQQLExhE
b21haW4gQ29udHJvbCBWYWxpZGF0ZWQxITAfBgNVBAsTGFBvc2l0aXZlU1NMIE11
bHRpLURvbWFpbjETMBEGA1UEAxMKcmF3Z2l0LmNvbTCCASIwDQYJKoZIhvcNAQEB
BQADggEPADCCAQoCggEBALD2sUNSYp2R+mSEMF2qKvMS780qTkltvqP4rwEGKOLV
rR5QQWo8vzSlgZvxVsguRHi0pPBtVAH794L43tD+IuyQlDlNU2qc1aMqBkn3S2wN
Way8BS9w80pgeFnObZiFJtPI9pdwcB72Bgq8Nlc25oVrDVWr/Q8nLIKS/9FkNs+C
MPU00vGFZSFbR7s15ORj9+qPCskWZcHpQ+m9EKmZD3IVKj3QQyBD17cBoVkYoIpj
I8/r+NfVfKetlsB7Pcv8P3yLFVFC4+PGrnW9TLZK9aRtbDTvjElXwCQIPK5B2fKw
SJK26IJPDX0r1JkeuR53Afr509iEx3xAHcRz/kR5uo8CAwEAAaOCAf0wggH5MB8G
A1UdIwQYMBaAFJCvajqUWgvYkOoSVnPfQ7Q6KNrnMB0GA1UdDgQWBBSI83Kqafo7
kg9cmyT1vnceH4fAUjAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAdBgNV
HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwTwYDVR0gBEgwRjA6BgsrBgEEAbIx
AQICBzArMCkGCCsGAQUFBwIBFh1odHRwczovL3NlY3VyZS5jb21vZG8uY29tL0NQ
UzAIBgZngQwBAgEwVAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5jb21vZG9j
YS5jb20vQ09NT0RPUlNBRG9tYWluVmFsaWRhdGlvblNlY3VyZVNlcnZlckNBLmNy
bDCBhQYIKwYBBQUHAQEEeTB3ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LmNvbW9k
b2NhLmNvbS9DT01PRE9SU0FEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0Eu
Y3J0MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wSwYDVR0R
BEQwQoIKcmF3Z2l0LmNvbYIVY2RuLW9yaWdpbi5yYXdnaXQuY29tgg5jZG4ucmF3
Z2l0LmNvbYINcmF3Z2l0aHViLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEASx3e6y9e
9F67N5NIDausDHDL+/fz6uj2DDNJdaQvALAYDV8hKpz7+QGotlQfI042U2i83J7m
DGZiKDpzaXa7IFfGiq6PFPUjntElHoU2E4vRb5LApg1sJ5YueYmRd3X7x0/jPYg6
TiTtHyXOnlMuqL2FUYJM0BP7cMfOppvUF0R2zHUVA0rVHuLrSStg8bMg8aVUIkKg
n3NS+eg4ofo95jaMRVykhLnylkYBk9dWBM6B19Yw7LgQd94MZSa+Xix4HxeFgvAM
BF+oeDMaY7mEN4Xm5hnrIS1FElRDq/ckpDV8JVpR4SQqB+tzSZPPIiep8EvgRDzd
2EkL187FF3MQ+w==
-----END CERTIFICATE-----
subject=/OU=Domain Control Validated/OU=PositiveSSL Multi-Domain/CN=rawgit.com
issuer=/C=GB/ST=Greater Manchester/L=Salford/O=COMODO CA Limited/CN=COMODO RSA Domain Validation Secure Server CA
---
No client certificate CA names sent
---
SSL handshake has read 6716 bytes and written 456 bytes
---
New, TLSv1/SSLv3, Cipher is DHE-RSA-AES256-SHA
Server public key is 2048 bit
Secure Renegotiation IS supported
Compression: NONE
Expansion: NONE
SSL-Session:
    Protocol  : TLSv1
    Cipher    : DHE-RSA-AES256-SHA
    Session-ID: ACFED5197F4B5F64DBAAC97A47380CD9283EFD1797E27EF43A215B06982698F4
    Session-ID-ctx: 
    Master-Key: 856DFB81C863F461FE75EE11E633EA5CAB3CD6683563F597F406EF19AB37CD3F45B4FE659AB8726F9291360CBE856F48
    Key-Arg   : None
    Start Time: 1475223830
    Timeout   : 300 (sec)
    Verify return code: 0 (ok)
---

BEGIN到END那段剪貼下來即是

缺點

這兩個方法有一個極大的缺點, 如果沒有解決方案還是最好別用(啊講那麼多, 最後不能用?!) , 這缺點就是SSL Certificate是有時效性的, 因此server端的憑證是會換會改變的, 只要server端一換, 就可能造成client端的失效, 所以如果要使用這兩個方法, 前提必須先解決的是如何更新Client端要檢查的public key或certificate, 這邊就不討論這部份的機制了, 這應該有很多方法可以來作才對

#好久沒寫關於Android的內容了

不知不覺的突然就多出了兩天颱風假, 這颱風實在很威, 乒乒乓乓的, 不過, 也沒做什麼, 時間就快過完了, 現在才想到, 還是來寫點什麼, 嚴格說來這些東西並不完全是颱風假時弄的, 只是拖得有點久

起因是, 之前(很久…追朔到去年)想寫個App, 需要用到中華職棒賽程的資料, 拖了很久一直沒真的去做, 斷斷續續的, 最近才先把資料這部分補齊, 首先需求是:

  1. 當月之後的賽程資料, 但中華職棒並沒有API, 只有(很爛)的網頁, 因此資料勢必得從網頁去解析
  2. 由Client app直接去解析html, 會比較麻煩(如果網站更新了, 就要更新App), 不是那麼可行
  3. 不想花錢(或不想花太多錢)弄一個server, 更不用說還要考慮Scaling

而賽程表這樣的資料的特性則是:

  1. 球季是3~10月
  2. 資料內容除週一(休賽)外, 幾乎每天都會變, 但不會一兩個小時或幾分鐘就變一次
  3. 變動的內容可能是: 4. 比賽結束, 比數有更新 5. 延賽或停賽
  4. 一個月才幾十場比賽而已, 基本上不太需要有search或query的功能, 依據月份分類也就足夠了

因此我採用的做法是:

  1. 利用AWS lambda定時解析中華職棒網站的資料
  2. 資料以json格式存在github (使用Github api)
  3. Client透過CDN去要這些json的raw content

賽程解析

這部分我是用Go + Goquery來寫的, source code在這邊: cpblschedule, 這code沒啥整理過, 光解析這堆亂七八糟的html就夠頭痛囉, 就沒啥整理

我做成了一個package, 因此要使用可用下列指令先安裝:

go get -u github.com/julianshen/cpblschedule

裡面也很簡單就一個function而已, 因此要使用可以參考:

import "github.com/julianshen/cpblschedule"

func main() {
	matches, err := cpblschedule.ParseCPBLSchedule(year, month)
    ....
}

AWS Lambda

這邊就不介紹這東西是什麼了, 網路上文章一大堆, 基本上他是AWS一個severless的解決方案(這算廣告詞吧), 會使用這個的原因有二:

  1. 依我的用量應該是免費(事實證明, 其實還是要花點錢, 我忘了算網路傳輸的費用了, 不過這不多)
  2. 可以用Cloud watch排程觸發

不過有個小問題

**他不支援Go!!!!!**

而我上面那個解析的東東是go寫的, 那不就寫心酸的

所幸還有別的辦法,就是把程式編譯成執行檔, 然後用nodejs去包裝它, 不過這有點煩瑣, 所幸還有工具

apex, 這是讓你更簡單的去建立lambda function的工具, 而且他正好也可以幫你簡單做好上面所說的包裝

安裝及使用就看文件吧, 不特別說了, 但要如何用go寫一個lambda function handle呢?以下是範例:

import (
	"encoding/json"
	"github.com/apex/go-apex"
)
func main() {
	apex.HandleFunc(func(event json.RawMessage, ctx *apex.Context) (interface{}, error) {
		dosomething()
		return nil, nil
	})
}

Github as API source

資料既然變動不頻繁, 就用lambda定期產生然後把結果放到Github上就可了

Github API的Go的實做是Google放出來的go-github, 文件還蠻眼花撩亂的, 不過在這應用需要的API不多:

  1. client.Repositories.GetContents - 取得內容
  2. client.Repositories.CreateFile - 創立一個新檔
  3. client.Repositories.UpdateFile - 更新某個檔

之所以需要1的原因是要確認檔案是不是已經在repository裡面了, 如果沒有就用create, 如果有就拿SHA hash去更新內容

GetContents會把檔案內容一併給抓回來, 這可以用來在更新檔案前先比較, 如果不比較, 就算沒更動, API也會新增一個新的commit, 為了避免不要太誇張, 還是先比較一下好了

那之後client怎樣存取這些資料? 找到檔案, 選取raw就可以知道raw的url了, client每次就抓這個URL就好, 但為了避免過量地request湧到github, 因此透過一個CDN來存取可能會好一點

這時候就可以用RawGit, 這邊透過MaxCDN, 讓你可以去存取Github上的raw content, 而你的檔案的網址會是像這樣:

https://cdn.rawgit.com/user/repo/tag/file

大致上就這樣

最近直播蠻火紅的, 直播服務也不少, 連Facebook都做了直播功能, 最近也跟人聊了不少這方面的東西, 所以想說趁中秋連假來自己研究看看, 只是中秋節連假雖然多, 做的事情還真是不少, 看電影, 打球, 打電動, 看球的, 又碰上颱風天, 不過還是先搞個簡單的雛形唄

相關應用

市面上有不少關於直播的應用, 應該說, 簡直是氾濫, 而且每一種人氣似乎都是很旺, 還不是很了解在旺啥的, 現在可能也不少人認為當個網紅就可以一炮而紅之類的

  • Live.me
  • 小米直播
  • 17
  • 麥卡貝

這邊其實可以看出, 根本大同小異, 大多是賣肉…喔, 不是, 賣網紅, 後面故意舉了一個麥卡貝當例子, 它是稍微不一樣, 以賣直播節目像是運動比賽的轉播(嗚~~金鋒退休了). 而不是一般的UGC(User generated), 當然這邊也沒舉一般很流行的遊戲直播像是Twitch, 不過這類早已為大家所熟知了

不管是哪一種, 大部分的設計都是大同小異, 都是以單向直播為主, 輔以文字聊天室, 可以送個愛心或禮物, Facebook稍稍進階點, 會將直播過程錄製下來, 不只錄製節目, 還有過程中的互動, 不過大家都是蠻近似的

基本原理

如果照以上的功能設計, 簡單的可以畫成兩個部分, 一個是直播串流(Video Stream)的部分, 一個則是聊天室的部分, 大致上的後端可以以這兩個為核心

關於直播串流的技術部分, Facebook 曾分享了一篇關於他們做直播串流經驗的文章:

Under the hood: Broadcasting live video to millions

從這邊可以了解到, 串流需要能夠支撐到非常多人同時觀賞, 網紅可能數百到數千, 像是蘋果的發表會, 或是Google I/O, WWDC 這種會議則可能是數萬到數十萬, 所以服務的高並發, 高流量是可以預期的, 架構上也要能夠承受這樣的強度, 簡化的畫起來應該像是這樣:

graph LR; C[Client]--publish-->M[Media Server]; M--forward-->E(Edge Server); M--forward-->E2(Edge Server); M--forward-->E3(Edge Server); E-->V(Viewer); E-->V2(Viewer); E2-->V3(Viewer); E2-->V4(Viewer); E3-->CDN; CDN-->V5(Viewer); CDN-->V6(Viewer); CDN-->V7(Viewer);

Viewer不直接從Media Server取串流內容是考量到Media server通常要接收多個Client發佈的串流, 一個假設性的想法是, 對於UGC(User generated content), 主播應該遠少於觀眾, 假設就算一個服務可以吸引到百萬級別的觀眾, 同時線上的主播應該了不起是幾千個而已, 即便如此, Media server本身從Client接收發布的串流資料後, 可能還需要做轉碼(transcoding), 和轉送的動作, 尤其是轉碼是較為耗CPU資源的工作, 如果把主播跟觀眾放在同一個伺服器上, 除了影響品質外, 也會不方便擴充, 因此減少Media server上的"觀眾"(讓觀眾只是其他少數的edge servers), 便可以在觀眾增加時相對好擴充容量(增加edge的數量)

但大部分的狀況來說, 每個直播的觀眾不一定是非常大量, 在Facebook那篇文章內也有提到, 在小量觀眾的狀況下, 分流到多個edge的效率應該就沒那麼的好了, 反而這時候放在同一台server減少延遲會是更好的選擇

串流的通訊協定有不少, 像是RTP, RTSP, RTMP, HLS, WebRTC等等, Facebook那篇文章主要提到的是RTMP, HLS, 查了一下資料, 似乎這兩個也是目前比較主流做直播用的, 雖然WebRTC被討論的也蠻多的, 但似乎比較沒被應用在大量的直播, RTMP跟HLS都是可以透過HTTP來做傳輸(RTMP需要做封裝 - RTMPT), 讓他們具有穿越防火牆的優勢, 而HLS是以檔案為基礎的, 所以適合用一般的CDN來做快取, 在做大量的直播優勢較大, 缺點是延遲太長了, 但這兩者其實也是可以合併使用的, 在小群體時用RTMP, 等觀眾成長到一定數量實再導流到HLS去

Proof of concept 的初步想法與簡單的設計

幾個初步的想法

  1. 直播 = live stream + chat room
  2. 現在直播應用很多, 所以應該不少現成的open source解決方案可以套用, POC可以從這些東西下手, 不用重造輪子
  3. 需求: Client發布直播後, Viewer可以知道現在有誰在直播並觀看, 並可以透過訊息聊天

發布

sequenceDiagram participant Client participant Register participant MediaServer participant Viewer Client->>Register: I want to go live activate Register Register-->>Client: Here is your ID and token deactivate Register Client->>MediaServer: Publish(id,token) activate MediaServer MediaServer->>Register: token valid? activate Register Register-->>MediaServer: Yes deactivate Register MediaServer-->>Client: OK deactivate MediaServer Client->>Register: I am ready to live activate Register Register->>Viewer: Somebody is ready to live activate Viewer deactivate Register Viewer->Viewer: Update UI deactivate Viewer

觀賞直播

這部分就沒什麼特別的了, 當一般的chat room做就好

先看一下成果

這成品有點粗劣, 有點不好意思 :P

我publish client只實作iOS版本, 而Viewer只實作了Android版本(根本只各做一半嘛!!/翻桌), 後端用firebase處理資料的部分, 所以即時通知新的直播, 和聊天沒啥問題(但我聊天的UI還是沒刻 >"<)

相關解決方案

既然沒有重新做輪子, 自然用了不少Open source的解決方案來達成, 從Server到Android, iOS要找到相關可用的實在不難, 可以說這部份實在是太成熟了, 研究完後覺得Facebook那篇文章也只是一般般而已

Steaming server

RTMP相關的解決方案還算不少, 這邊列幾個

  1. Nginx RTMP Module - 架構在Nginx之上, 也算老牌了, 支援RTMP和HLS, 但看code base, 實在也沒啥在更新
  2. Mona server - 支援RTMP, HTTP(非HLS), Web socket等等
  3. Red5 Media Server - 支援RTMP, HLS, WebSocket, RTSP, 好像是要錢
  4. Simpe RTMP Server - 這是由中國的觀止雲這家開源出來的, 講"Simple"其實一點都不Simple, 輕量, 穩定(至少我試直播一晚上都還蠻順利的), 好擴展(支援forward to edge), 可RTMP轉HLS, 因此我最後選擇這個方案
SRS (Simple RTMP Server)

硬體

我沒看到文件有寫硬體需求, 但我用Digital Ocean 1GB RAM, 30GB SSD的Droplet跑, 單一個直播, 直播好幾個鐘頭, CPU都不超過5%, 所以應該足夠

安裝

安裝上相當簡單

  1. 從git上抓下來: git clone https://github.com/ossrs/srs.git
  2. 切換到相對應版本的branch(我是用2.0release) - git checkout 2.0release
  3. cd srs/trunk; ./configure ; make ;

建置好的執行檔會在srs/trunk/objs目錄下, 可以直接執行

設定

conf目錄下有很多不同的設定檔可以參考, 因為我要試RTMP, HLS所以我用的設定檔如下:

listen              1935;
max_connections     1000;
srs_log_tank        file;
srs_log_file        ./objs/srs.log;
http_api {
    enabled         on;
    listen          1985;
}
http_server {
    enabled         on;
    listen          8080;
    dir             ./objs/nginx/html;
}
stats {
    network         0;
    disk            sda sdb xvda xvdb;
}
vhost __defaultVhost__ {
    hls {
        enabled         on;
		hls_fragment    10;
        hls_window      60;
        hls_path        ./objs/nginx/html;
        hls_m3u8_file   [app]/[stream].m3u8;
        hls_ts_file     [app]/[stream]-[seq].ts;
    }
}

相對應的設定可以參考文件

執行

很簡單: srs -c my.conf 即可

iOS RTMP Publish

找到iOS支援RTMP publish的解決方案有幾種:

  1. VideoCore - 這個我還沒去試過, 不知道好不好用, 但似乎有支援Filter和Watermark, 感覺蠻威的
  2. lf.swift - 簡單, Swift做的, 這兩個是優點, 對我這個只看得懂Swift看不懂ObjC的, debug是比較方便, 但除此之外好像也沒啥特色了
  3. LiveVideoCoreSDK - 這文件不多, 我暫時就沒試了, 也支援濾鏡, 而且似乎這個作者也提供了Android版本, 只是好像沒支援Cocoapods或Cathage, 有空再來玩玩
  4. LFLiveKit - 我最後是選用這個, 簡單, 且"自帶美顏"(現在騙人是很基本的)
lf.swift

雖然文件上有提供cocoapods跟Carhtage的安裝方式, 但絕對不要用Carthage的那個, 第一原因是它Carthage支援似乎尚未搞定, 就算改點東西解決了它, 是可以安裝成功沒錯, 但會崩潰在XCGLogger, 似乎用framework含進來的方式會導致XCGLogger == nil, 這害我花了好多時間, 畢竟我是Carthage的愛用者, 後來轉用Cocoapods就沒事了

幾個需要加的部分:

AppDelegate.swift

func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {

        XCGLogger.defaultInstance().outputLogLevel = .Info
        XCGLogger.defaultInstance().xcodeColorsEnabled = true
        
        return true
    }

這兩行是設定logger要記錄的東西, 方便debug用

ViewController

override func viewDidLoad() {
	rtmpStream = RTMPStream(rtmpConnection: rtmpConnection)
    rtmpStream.syncOrientation = true
    rtmpStream.attachAudio(AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeAudio))
        rtmpStream.attachCamera(DeviceUtil.deviceWithPosition(.Back))
    rtmpStream.addObserver(self, forKeyPath: "currentFPS", options: NSKeyValueObservingOptions.New, context: nil)
        
    rtmpStream.captureSettings = [
            "sessionPreset": AVCaptureSessionPreset1280x720,
            "continuousAutofocus": true,
            "continuousExposure": true,
        ]

    rtmpStream.videoSettings = [
            "width": 1280,
            "height": 720,
        ]
    lfView.attachStream(rtmpStream)

    view.addSubview(lfView)
}

直播的部分會跟他的LFView綁一起

LFLiveKit

後來選用LFLiveKit的原因不是因為他自帶美顏 :D, 他寫法跟lf.swift一樣簡單, 而且不一定要把preview加到UI裡面, 而且他的preview不用用特定的class,只要是UIView就可

範例直接貼他文件裡的就很清楚了:

// import LFLiveKit in [ProjectName]-Bridging-Header.h
import <LFLiveKit.h> 

//MARK: - Getters and Setters
lazy var session: LFLiveSession = {
    let audioConfiguration = LFLiveAudioConfiguration.defaultConfiguration()
    let videoConfiguration = LFLiveVideoConfiguration.defaultConfigurationForQuality(LFLiveVideoQuality.Low3, landscape: false)
    let session = LFLiveSession(audioConfiguration: audioConfiguration, videoConfiguration: videoConfiguration)

    session?.delegate = self
    session?.preView = self.view
    return session!
}()

//MARK: - Event
func startLive() -> Void { 
    let stream = LFLiveStreamInfo()
    stream.url = "your server rtmp url";
    session.startLive(stream)
}

func stopLive() -> Void {
    session.stopLive()
}

//MARK: - Callback
func liveSession(session: LFLiveSession?, debugInfo: LFLiveDebug?) 
func liveSession(session: LFLiveSession?, errorCode: LFLiveSocketErrorCode)
func liveSession(session: LFLiveSession?, liveStateDidChange state: LFLiveState)

Android stream player

寫到後面有點累了, 也有點懶了, 還剩下Android這塊沒寫, 這邊就只列出方案不寫細節了, 主要我測過:

  1. ExoPlayer: Google開源的Media player, 之前在做另一個東西時我有用過, 所以第一時間就想起這個, 不過, 原生的完全不支援RTMP, 不過可以參考這邊, 但我實際上用, RTMP完全沒成功過, 一直出現FLV parse的問題, 倒是HLS沒問題
  2. Ijkplayer - 這同時有Android和iOS版本, 是Bilibili開源的, 有點強大, 但基於ffmpeg, 不知道在license上會不會有風險, 使用起來還有點複雜, 但HLS, RTMP都是沒問題
  3. PLDroidPlayer - 七牛雲針對ijkplayer的再製品, 比較方便的是, 它有封裝出一個video view可以直接使用, 相較於ijkplayer來說比較簡單易用

對一個developer的blog來說, 流程圖似乎是蠻需要的, 比較能夠清楚來解釋一些東西, 但每個東西都轉圖檔還蠻麻煩的, 下面介紹一個有用的Jekyll plugin, 可以做到像下面這樣的效果:

第一例

graph TD; A-->B; A-->C; B-->D; C-->D;

第二例

sequenceDiagram participant John participant Alice Alice->>John: Hello John, how are you? John-->>Alice: Great!

這是利用一個叫做Jekyll-mermaid 來達成的

而這plugin其實也沒做很多事, 它是包裝了mermaid這個工具, 而mermaid這工具他是利用了ds.js來讓你用很簡單的方式來繪製流程, 以上面兩個例子為例

第一例

graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;

第二例

sequenceDiagram
    participant John
    participant Alice
    Alice->>John: Hello John, how are you?
    John-->>Alice: Great!

所以你在markdown裡面只要加上

{ % mermaid % }
sequenceDiagram
    participant John
    participant Alice
    Alice->>John: Hello John, how are you?
    John-->>Alice: Great!
{ % endmermaid % }

他就會幫你render出相關的流程了

安裝方法

這邊以我自己blog的安裝方法來說明

  1. 把jekyll-mermaid.rb放到_plugins目錄去
  2. 在_config.yml加上 (這邊以6.0.0的mermaid為例):
mermaid:
  src: 'https://cdn.rawgit.com/knsv/mermaid/6.0.0/dist/mermaid.js'
  1. 還要在head.html加上css (要配合版面顏色), 可以用這個 : https://cdn.rawgit.com/knsv/mermaid/6.0.0/dist/mermaid.css

承續上篇的用icon font來製作圖示, 之前所提到的都是利用現成的icon font, 但似乎大部分的icon font都沒有像material icon有支援ligatures, 沒支援的話, 在xcode裡面就無法像上一篇一樣, 直接在UI designer顯示對應的圖示, 另外如果需要使用自己的圖示呢?其實是有方法用SVG圖檔來製作自己的icon font的, 這篇就來介紹兩種用SVG圖檔製作一個有ligatures支援的字型檔

grunt-webfont

第一個方法就是利用grunt-webfont, Grunt是一個前端常用的建構工具, 而grunt-webfont是一個用來產生字型的task

安裝相關工具

由於需要使用Grunt, node.js是必須的, 另外由於需要使用到fontforge, 所以python也是必須的, 雖然說grunt-webfont也可以純nodejs的module來產生字型, 但那並無法支援ligatures, 所以fontforge是一定需要的

npm i grunt --global來安裝grunt

製作字型
  1. 建立一個空的目錄
  2. 在這個目錄執行npm init來產生package.json
  3. npm i grunt-webfont --save來安裝grunt-webfont並且把這個dependency 加到package.json
  4. 建立一個svg子目錄(目錄名稱隨你高興, 這邊以svg當例子), 把所有圖示的svg檔案全部放到這目錄去
  5. 建立Gruntfile.js , 這檔案就像是Makefile, 或像是build.gradle這樣的角色, 內容就像下面
module.exports = function(grunt) {

  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    webfont: {
        icons: {
                src: 'svg/*.svg',
                dest: 'build/fonts',
                options: {
                        engine: 'fontforge',
                        htmlDemo: true,
                        fontHeight: 96,
                        normalize: false,
                        ascent: 84,
                        descent: 12,
                        font: 'octicon',
                        fontFamilyName: 'octicon',
                        types: 'ttf',
                        ligatures: true,
                        startCodepoint: 0xF101
                    }
                }
    },
    clean: [
        'build/fonts/*'
      ]
  });

  grunt.loadNpmTasks('grunt-contrib-clean');
  grunt.loadNpmTasks('grunt-webfont');
  grunt.registerTask('font', ['clean', 'webfont']);
  grunt.registerTask('default', [ 'font']);
};

這邊最主要也最重要的task就是webfont這個, 這裡面src是svg檔的目錄, dest是字型輸出的目錄, engine的部分指名fontforge, ligatures設定必須要是true(產生的字型的ligature的名字其實就是沿用svg的檔名)

建立好這個檔後執行grunt即可

icomoon

上面的方法還是有點麻煩, 蠻手動的, 還有一個更方便的工具就是icomoon, 這東西方便更多, 它是一個相當強大的工具

Icomoon

從畫面上看, 它其實很簡單操作, 選定你所需要的圖示後, 按右下角的Generate Font即可, 除了你可以自己import自己的svg檔案外, 它也提供很多付費跟免費的圖示供選用:

Icomoon

按下Generate Font後, 並不會馬上讓你下載字型回家, 它會先讓你檢視字型將會包含的圖示, 這邊有件事很重要, 左上角有個fi圖示(參照下圖), 按下去後, 下面的圖示下面會多一個fi的欄位, 這就是讓你設定這些圖示的ligature的, 如果需要一個有支援ligature的字型, 就需要去設定這邊

Icomoon

所有都沒問題後按下右下角的Download就沒問題了