diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e3d70fa --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +ZIP_FILE_NAME="hls-enc-lambda" +BIN="bootstrap" + +GOOS="linux" +GOARCH="arm64" +CGO_ENABLED=0 + +LAMBDA_FUNCTION_NAME="hls-enc" + +compile: + @echo "Compiling..." + GOOS=$(GOOS) GOARCH=$(GOARCH) go build -tags lambda.norpc -o $(BIN) main.go + +bundle: + @echo "Bundling..." + zip $(ZIP_FILE_NAME).zip $(BIN) + +push: + @echo "Pushing..." + aws lambda update-function-code --function-name $(LAMBDA_FUNCTION_NAME) --zip-file fileb://$(ZIP_FILE_NAME).zip + +clean: + @echo "Cleaning..." + rm -f $(BIN) $(ZIP_FILE_NAME).zip + +deploy: clean compile bundle push diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..4038179 --- /dev/null +++ b/config/config.go @@ -0,0 +1,34 @@ +package config + +import ( + "net/url" + "os" + "time" +) + +type config struct { + Origin string + KeyID string + PrivateKeyBase64 string + ExpireTime time.Duration +} + +var ConfigEnv *config + +// InitConfig initialize the configuration +func InitConfig() { + ConfigEnv = load() +} + +// load configuration from environment variables +func load() (c *config) { + origin, _ := url.Parse(os.Getenv("CLOUDFRONT_ORIGIN")) + expireTime, _ := time.ParseDuration(os.Getenv("EXPIRE_TIME")) + c = &config{ + Origin: origin.Host, + KeyID: os.Getenv("CLOUDFRONT_ACCESS_KEY_ID"), + PrivateKeyBase64: os.Getenv("CLOUDFRONT_PRIVATE_KEY_BASE64"), + ExpireTime: expireTime, + } + return +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7beafde --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/meanii/aws-lambda-hls-enc + +go 1.22.0 + +require ( + github.com/aws/aws-lambda-go v1.46.0 + github.com/aws/aws-sdk-go v1.50.27 +) + +require ( + github.com/awslabs/aws-lambda-go-api-proxy v0.16.1 // indirect + github.com/grafov/m3u8 v0.12.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2a09e4b --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/aws/aws-lambda-go v1.46.0 h1:UWVnvh2h2gecOlFhHQfIPQcD8pL/f7pVCutmFl+oXU8= +github.com/aws/aws-lambda-go v1.46.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= +github.com/aws/aws-sdk-go v1.50.27 h1:96ifhrSuja+AzdP3W/T2337igqVQ2FcSIJYkk+0rCeA= +github.com/aws/aws-sdk-go v1.50.27/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/awslabs/aws-lambda-go-api-proxy v0.16.1 h1:x4F/VbWYt/f5K9+n3TAqbjFljDP52KWbYz/fNBvQdi8= +github.com/awslabs/aws-lambda-go-api-proxy v0.16.1/go.mod h1:31WDgvTzVyra022CWzO6uEZFel9/y7QKaZpUQEqYLr0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/grafov/m3u8 v0.12.0 h1:T6iTwTsSEtMcwkayef+FJO8kj+Sglr4Lh81Zj8Ked/4= +github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/binary_chunks.go b/handler/binary_chunks.go new file mode 100644 index 0000000..bdceb92 --- /dev/null +++ b/handler/binary_chunks.go @@ -0,0 +1,32 @@ +package handler + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/meanii/aws-lambda-hls-enc/config" + "github.com/meanii/aws-lambda-hls-enc/helper" +) + +// BinaryChunks is the handler for binary chunks +// It will sign the URL and redirect to the signed URL +func BinaryChunksRedirection(cfUrl url.URL, w http.ResponseWriter, r *http.Request) { + signedUrlSchema := helper.URLSigner{ + KeyID: config.ConfigEnv.KeyID, + PrivKeyBase64: config.ConfigEnv.PrivateKeyBase64, + Hour: config.ConfigEnv.ExpireTime, + } + + signedChunkedUrl, err := signedUrlSchema.Signer(cfUrl.String()) + if err != nil { + fmt.Printf("Error signing URL: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("Error signing URL: %s\n", err))) + return + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Location", signedChunkedUrl) + http.Redirect(w, r, signedChunkedUrl, http.StatusFound) +} diff --git a/handler/http_handler.go b/handler/http_handler.go new file mode 100644 index 0000000..747054c --- /dev/null +++ b/handler/http_handler.go @@ -0,0 +1,21 @@ +package handler + +import ( + "net/http" +) + +// HandlerMethodOptions is the handler for OPTIONS method +func HandlerMethodOptions(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.WriteHeader(http.StatusOK) + } +} + +// HandlerMethodHead is the handler for HEAD method +func HandlerMethodHead(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + } +} diff --git a/handler/m3u8_handler.go b/handler/m3u8_handler.go new file mode 100644 index 0000000..a2dc929 --- /dev/null +++ b/handler/m3u8_handler.go @@ -0,0 +1,71 @@ +package handler + +import ( + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/grafov/m3u8" + + "github.com/meanii/aws-lambda-hls-enc/config" + "github.com/meanii/aws-lambda-hls-enc/helper" +) + +// M3u8Media is the handler for m3u8 media playlist +// It will return the media playlist +func M3u8Media(mediapl *m3u8.MediaPlaylist, w http.ResponseWriter, _ *http.Request) { + mediaPlaylistString := mediapl.String() + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write([]byte(mediaPlaylistString)) +} + +// M3u8Master is the handler for m3u8 master playlist +// It will return the master playlist +func M3u8Master( + clurl url.URL, + masterpl *m3u8.MasterPlaylist, + w http.ResponseWriter, + _ *http.Request, +) { + masterPlaylistString := masterpl.String() + for _, variant := range masterpl.Variants { + + baseName := path.Base(clurl.String()) + variantUrl := strings.Replace(clurl.String(), baseName, variant.URI, 1) + + // This is the URLSigner struct, it is used to sign the URL + signedUrlSchema := helper.URLSigner{ + KeyID: config.ConfigEnv.KeyID, + PrivKeyBase64: config.ConfigEnv.PrivateKeyBase64, + Hour: config.ConfigEnv.ExpireTime, + } + signedVariantUrl, err := signedUrlSchema.Signer(variantUrl) + if err != nil { + fmt.Printf("Error signing URL: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("Error signing URL: %s\n", err))) + return + } + + // Replace the original variant URL with the signed variant URL + signedVariantURL, _ := url.Parse(signedVariantUrl) + signedChunkUrl := variant.URI + "?" + signedVariantURL.RawQuery // Add the query string + masterPlaylistString = strings.Replace( + masterPlaylistString, + variant.URI, + signedChunkUrl, + -1, + ) + + } + + // Set the headers and write the response + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/x-mpegURL") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write([]byte(masterPlaylistString)) +} diff --git a/helper/fetch.go b/helper/fetch.go new file mode 100644 index 0000000..3e4920f --- /dev/null +++ b/helper/fetch.go @@ -0,0 +1,14 @@ +package helper + +import ( + "net/http" +) + +// Fetch fetches the content of the given URL +func Fetch(url string) (*http.Response, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/helper/signer.go b/helper/signer.go new file mode 100644 index 0000000..f405282 --- /dev/null +++ b/helper/signer.go @@ -0,0 +1,52 @@ +package helper + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "time" + + "github.com/aws/aws-sdk-go/service/cloudfront/sign" +) + +// URLSigner is a struct that holds the key ID, private key, raw URL, and hour. +type URLSigner struct { + KeyID string // Credential Key Pair key ID + PrivKeyBase64 string // private key in base64 + Hour time.Duration // hour duration +} + +// Signer signs a URL to be valid, using the private key and credential pair key ID. +func (us *URLSigner) Signer(url string) (string, error) { + // Load the private key, and return an error if it fails + privKeyBytes, err := loadPrivateKey(us.PrivKeyBase64) + if err != nil { + return "", err + } + + // Create a new URLSigner with the key ID and private key + signer := sign.NewURLSigner(us.KeyID, privKeyBytes) + signedURL, err := signer.Sign(url, time.Now().Add(us.Hour)) + return signedURL, nil +} + +// loadPrivateKey loads a RSA private key from the file path. +func loadPrivateKey(privKeyBase64 string) (*rsa.PrivateKey, error) { + // Decode the base64 string to bytes + privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyBase64) + if err != nil { + return nil, err + } + + // Decode the PEM block to get the private key + block, _ := pem.Decode(privKeyBytes) + + // Parse the private key + // #TODO: handle fallback to PKCS1 if PKCS8 fails to parse + privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + return privKey.(*rsa.PrivateKey), nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..29f24a8 --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "net/http" + + "github.com/aws/aws-lambda-go/lambda" + "github.com/awslabs/aws-lambda-go-api-proxy/httpadapter" + + "github.com/meanii/aws-lambda-hls-enc/config" + "github.com/meanii/aws-lambda-hls-enc/server" +) + +func main() { + config.InitConfig() + http.HandleFunc("/", server.HandleRequest) + + // Start the Lambda proxy + lambda.Start(httpadapter.New(http.DefaultServeMux).ProxyWithContext) +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..4a358a2 --- /dev/null +++ b/server/main.go @@ -0,0 +1,95 @@ +package server + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/grafov/m3u8" + + "github.com/meanii/aws-lambda-hls-enc/config" + "github.com/meanii/aws-lambda-hls-enc/handler" + "github.com/meanii/aws-lambda-hls-enc/helper" +) + +func HandleRequest(w http.ResponseWriter, r *http.Request) { + // handle OPTIONS method + if r.Method == http.MethodOptions { + handler.HandlerMethodOptions(w, r) + return + } + + // handle HEAD method + if r.Method == http.MethodHead { + handler.HandlerMethodHead(w, r) + return + } + + requestFullUrl := r.URL.String() + fmt.Printf("Request URL: %s\n", requestFullUrl) + + cloudfrontUrl, err := url.Parse(requestFullUrl) + if err != nil { + fmt.Printf("Error parsing URL: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + // set the cloudfront URL host to the origin + // example: https://lambda-function.com -> https://origin-cdn.com + cloudfrontUrl.Host = config.ConfigEnv.Origin + + // check if has suffix .ts, *.key return the file + if strings.HasSuffix(requestFullUrl, ".ts") || strings.HasSuffix(requestFullUrl, ".key") { + handler.BinaryChunksRedirection(*cloudfrontUrl, w, r) + return + } + + // fetch m3u8 file from cloudfront + m3u8Resp, err := helper.Fetch(cloudfrontUrl.String()) + defer m3u8Resp.Body.Close() + + // return 500 if error fetching m3u8 file + // it may be not valid signed URL + // master file must be signed, before calling to this lambda function + // example: https://lambda-function.com/master.m3u8?Expires=1610000000&Signature=xxxx&Key-Pair-Id=xxxx + // but the master.m3u8 signed with cdn-origin.com + if err != nil { + fmt.Printf("Error fetching m3u8: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // decode m3u8 file, this is a common library for m3u8 file parsing + // it will return the type of the m3u8 file, and the parsed object of the m3u8 file + p, listType, err := m3u8.DecodeFrom(m3u8Resp.Body, true) + if err != nil { + fmt.Printf("Error decoding m3u8: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("Error decoding m3u8: %s\n", err))) + return + } + + // check the type of the m3u8 file, then sign the URL and return the signed URL + // listType, m3u8.MEDIA, m3u8.MASTER are the types of the m3u8 file + switch listType { + + case m3u8.MEDIA: + // cast the parsed object to m3u8.MediaPlaylist + // then call the handler.M3u8Media to return the media playlist + mediapl := p.(*m3u8.MediaPlaylist) + handler.M3u8Media(mediapl, w, r) + return + + case m3u8.MASTER: + // cast the parsed object to m3u8.MasterPlaylist + // then call the handler.M3u8Master to return the master playlist + masterpl := p.(*m3u8.MasterPlaylist) + handler.M3u8Master(*cloudfrontUrl, masterpl, w, r) + return + } + + // if the m3u8 file is not a media or master playlist, return 500 + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Error decoding m3u8: unknown list type")) +}