├── .dockerignore ├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.md ├── docker-compose.yml ├── nginx.conf ├── nginx.remote.conf └── scripts ├── generate_signing_key.py └── generate_signing_key_heroku.py /.dockerignore: -------------------------------------------------------------------------------- 1 | videos/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | videos/* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: bash 3 | services: docker 4 | install: 5 | - pip install requests 6 | env: 7 | - DOCKER_IMAGE=nginx-vod-module 8 | script: 9 | - docker build -t ${DOCKER_IMAGE} . 10 | after_script: 11 | - docker images 12 | - docker run -d -p 8080:80 -t ${DOCKER_IMAGE} 13 | - docker ps -a 14 | after_success: 15 | - if [ "$TRAVIS_BRANCH" == "master" ]; then 16 | python scripts/generate_signing_key_heroku.py -k ${AWS_SECRET_KEY} -r us-west-1 -hk ${HEROKU_API_KEY}; 17 | docker login --username _ --password "$HEROKU_API_KEY" registry.heroku.com; 18 | docker tag ${DOCKER_IMAGE} registry.heroku.com/vod/web; 19 | docker push registry.heroku.com/vod/web; 20 | fi -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.13 2 | 3 | ENV NGINX_VERSION 1.19.7 4 | ENV NGINX_VOD_MODULE_VERSION 1.27 5 | 6 | # Tempfix until auth module fixed 7 | ENV NGINX_AWS_AUTH_VERSION 2.1.1-patch-04062021 8 | 9 | EXPOSE 80 10 | 11 | RUN apk add --no-cache wget ca-certificates build-base openssl openssl-dev zlib-dev linux-headers pcre-dev ffmpeg ffmpeg-dev gettext 12 | 13 | # Get nginx source. 14 | RUN wget https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz \ 15 | && tar zxf nginx-${NGINX_VERSION}.tar.gz \ 16 | && rm nginx-${NGINX_VERSION}.tar.gz 17 | 18 | # Get nginx vod module. 19 | RUN wget https://github.com/kaltura/nginx-vod-module/archive/${NGINX_VOD_MODULE_VERSION}.tar.gz \ 20 | && tar zxf ${NGINX_VOD_MODULE_VERSION}.tar.gz \ 21 | && rm ${NGINX_VOD_MODULE_VERSION}.tar.gz 22 | 23 | # Get nginx aws auth module. 24 | RUN wget https://github.com/alfg/ngx_aws_auth/archive/${NGINX_AWS_AUTH_VERSION}.tar.gz \ 25 | && tar zxf ${NGINX_AWS_AUTH_VERSION}.tar.gz \ 26 | && rm ${NGINX_AWS_AUTH_VERSION}.tar.gz 27 | 28 | # Compile nginx with nginx-vod-module. 29 | RUN cd nginx-${NGINX_VERSION} \ 30 | && ./configure \ 31 | --prefix=/usr/local/nginx \ 32 | --add-module=../nginx-vod-module-${NGINX_VOD_MODULE_VERSION} \ 33 | --add-module=../ngx_aws_auth-${NGINX_AWS_AUTH_VERSION} \ 34 | --conf-path=/usr/local/nginx/conf/nginx.conf \ 35 | --with-file-aio \ 36 | --error-log-path=/opt/nginx/logs/error.log \ 37 | --http-log-path=/opt/nginx/logs/access.log \ 38 | --with-threads \ 39 | --with-cc-opt="-O3" \ 40 | --with-debug 41 | RUN cd nginx-${NGINX_VERSION} && make && make install 42 | 43 | COPY nginx.remote.conf /usr/local/nginx/conf/nginx.conf.template 44 | 45 | # Cleanup. 46 | RUN rm -rf /var/cache/* /tmp/* 47 | 48 | CMD /bin/sh -c "envsubst < /usr/local/nginx/conf/nginx.conf.template > \ 49 | /usr/local/nginx/conf/nginx.conf && \ 50 | /usr/local/nginx/sbin/nginx -g 'daemon off;'" 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker NGINX VOD Module 2 | 3 | [![Build Status](https://travis-ci.org/alfg/docker-nginx-vod-module.svg?branch=master)](https://travis-ci.org/alfg/docker-nginx-vod-module) 4 | [![Docker Automated build](https://img.shields.io/docker/automated/alfg/nginx-vod-module.svg)](https://hub.docker.com/r/alfg/nginx-vod-module/builds/) 5 | 6 | A Dockerized NGINX build with the `nginx-vod-module` and `ngx_aws_auth` for serving VOD content to DASH, HLS, and MSS. 7 | 8 | `nginx-vod-module` is configured in remote-mode with `ngx_aws_auth` to securely serve content stored in a private S3 bucket. 9 | 10 | 11 | # Setup 12 | #### Requirements 13 | * Docker 14 | * AWS Account for S3 15 | * Python 2.7 16 | 17 | #### Setup AWS Credentials 18 | * Create an AWS account and S3 Bucket (private) 19 | * Create a user via IAM with Programmatic Access and read access to the S3 Bucket 20 | * Run `python scripts/generate_signing_key.py -k -r ` to generate a signing_key and key_scope. 21 | 22 | #### Setup Server 23 | * Configure `docker-compose.yml` with the following environment variables: 24 | ``` 25 | - AWS_ACCESS_KEY= 26 | - AWS_S3_BUCKET= 27 | - AWS_SIGNING_KEY= 28 | - AWS_KEY_SCOPE= 29 | ``` 30 | 31 | * Start server: 32 | ``` 33 | docker-compose up 34 | ``` 35 | 36 | * Test 37 | Upload an MP4 to your S3 bucket and load the manifest to test different outputs: 38 | 39 | * DASH - http://localhost:8080/dash/video.mp4/manifest.mpd 40 | * HLS - http://localhost:8080/hls/video.mp4/master.m3u8 41 | * MSS - http://localhost:8080/mss/video.mp4/manifest 42 | 43 | ``` 44 | λ curl -I http://localhost:8080/dash/video.mp4/manifest.mpd 45 | HTTP/1.1 200 OK 46 | ``` 47 | 48 | Use one of the players below to test playback. 49 | 50 | #### Setup CDN 51 | TODO 52 | 53 | # Demo 54 | 55 | | Type | Source | URL | 56 | | ---- | --- | --- | 57 | | DASH | Origin | [Shaka Player](https://shaka-player-demo.appspot.com/demo/#asset=https://vod.herokuapp.com/dash/videos/tears-of-steel/tears-of-steel_,h264_baseline_360p_600.mp4,h264_main_480p_1000.mp4,h264_main_720p_3000.mp4,h264_main_1080p_6000.mp4,audio.mp4,.urlset/manifest.mpd;lang=en-US) | 58 | | HLS | Origin | [HLS.js PLayer](https://video-dev.github.io/hls.js/demo/?src=https%3A%2F%2Fvod.herokuapp.com%2Fhls%2Fvideos%2Ftears-of-steel%2Ftears-of-steel_%2Ch264_baseline_360p_600.mp4%2Ch264_main_480p_1000.mp4%2Ch264_main_720p_3000.mp4%2Ch264_main_1080p_6000.mp4%2Caudio.mp4%2C.urlset%2Fmaster.m3u8&enableStreaming=true&autoRecoverError=true&enableWorker=true&dumpfMP4=false&levelCapping=-1) | 59 | | MSS | Origin | [HASPlayer.js](http://orange-opensource.github.io/hasplayer.js/1.13.0/samples/DemoPlayer/index.html?url=https://vod.herokuapp.com/mss/videos/tears-of-steel/tears-of-steel_,h264_baseline_360p_600.mp4,h264_main_480p_1000.mp4,h264_main_720p_3000.mp4,h264_main_1080p_6000.mp4,audio.mp4,.urlset/manifest) | 60 | | DASH | CDN | [Shaka Player](https://shaka-player-demo.appspot.com/demo/#asset=https://d22kgg8psbxs19.cloudfront.net/dash/videos/tears-of-steel/tears-of-steel_,h264_baseline_360p_600.mp4,h264_main_480p_1000.mp4,h264_main_720p_3000.mp4,h264_main_1080p_6000.mp4,audio.mp4,.urlset/manifest.mpd;lang=en-US) | 61 | | HLS | CDN | [HLS.js Player](https://video-dev.github.io/hls.js/demo/?src=https%3A%2F%2Fd22kgg8psbxs19.cloudfront.net%2Fhls%2Fvideos%2Ftears-of-steel%2Ftears-of-steel_%2Ch264_baseline_360p_600.mp4%2Ch264_main_480p_1000.mp4%2Ch264_main_720p_3000.mp4%2Ch264_main_1080p_6000.mp4%2Caudio.mp4%2C.urlset%2Fmaster.m3u8&enableStreaming=true&autoRecoverError=true&enableWorker=true&dumpfMP4=false&levelCapping=-1) | 62 | | MSS | CDN | [HASPlayer.js](http://orange-opensource.github.io/hasplayer.js/1.13.0/samples/DemoPlayer/index.html?url=https://d22kgg8psbxs19.cloudfront.net/mss/videos/tears-of-steel/tears-of-steel_,h264_baseline_360p_600.mp4,h264_main_480p_1000.mp4,h264_main_720p_3000.mp4,h264_main_1080p_6000.mp4,audio.mp4,.urlset/manifest) | 63 | 64 | 65 | # Test Players 66 | HTML5 Players for testing. 67 | 68 | * https://shaka-player-demo.appspot.com 69 | * http://video-dev.github.io/hls.js/demo/ 70 | * http://orange-opensource.github.io/hasplayer.js/ 71 | 72 | 73 | # TODO 74 | * DRM Example 75 | * Test with S3-compatible APIs (Digital Ocean Spaces) 76 | 77 | 78 | # References 79 | * https://www.nginx.com 80 | * https://github.com/kaltura/nginx-vod-module 81 | * https://github.com/anomalizer/ngx_aws_auth 82 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | nginx-vod: 2 | build: . 3 | # image: alfg/nginx-vod-module 4 | ports: 5 | - 8080:80 6 | environment: 7 | - PORT=80 8 | - AWS_ACCESS_KEY= 9 | - AWS_SIGNING_KEY= 10 | - AWS_KEY_SCOPE= 11 | - AWS_S3_BUCKET= -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | use epoll; 5 | } 6 | 7 | http { 8 | log_format main '$remote_addr $remote_user [$time_local] "$request" ' 9 | '$status "$http_referer" "$http_user_agent"'; 10 | 11 | access_log /dev/stdout main; 12 | error_log stderr debug; 13 | 14 | default_type application/octet-stream; 15 | include /usr/local/nginx/conf/mime.types; 16 | 17 | sendfile on; 18 | tcp_nopush on; 19 | tcp_nodelay on; 20 | 21 | vod_mode local; 22 | vod_metadata_cache metadata_cache 2048m; 23 | vod_response_cache response_cache 128m; 24 | vod_last_modified_types *; 25 | vod_segment_duration 9000; 26 | vod_align_segments_to_key_frames on; 27 | vod_dash_fragment_file_name_prefix "segment"; 28 | vod_hls_segment_file_name_prefix "segment"; 29 | 30 | vod_manifest_segment_durations_mode accurate; 31 | 32 | open_file_cache max=1000 inactive=5m; 33 | open_file_cache_valid 2m; 34 | open_file_cache_min_uses 1; 35 | open_file_cache_errors on; 36 | 37 | aio on; 38 | 39 | server { 40 | listen 80; 41 | server_name localhost; 42 | root /opt/static; 43 | 44 | location ~ ^/videos/.+$ { 45 | autoindex on; 46 | } 47 | 48 | location /hls/ { 49 | vod hls; 50 | alias /opt/static/videos/; 51 | add_header Access-Control-Allow-Headers '*'; 52 | add_header Access-Control-Allow-Origin '*'; 53 | add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; 54 | } 55 | 56 | location /dash/ { 57 | vod dash; 58 | alias /opt/static/videos/; 59 | add_header Access-Control-Allow-Headers '*'; 60 | add_header Access-Control-Allow-Origin '*'; 61 | add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; 62 | } 63 | 64 | location /mss/ { 65 | vod mss; 66 | alias /opt/static/videos/; 67 | add_header Access-Control-Allow-Headers '*'; 68 | add_header Access-Control-Allow-Origin '*'; 69 | add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /nginx.remote.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | use epoll; 5 | } 6 | 7 | http { 8 | log_format main '$remote_addr $remote_user [$time_local] "$request" ' 9 | '$status "$http_referer" "$http_user_agent"'; 10 | 11 | access_log /dev/stdout main; 12 | error_log stderr debug; 13 | 14 | default_type application/octet-stream; 15 | include /usr/local/nginx/conf/mime.types; 16 | 17 | # tcp_nopush on; 18 | tcp_nodelay on; 19 | 20 | 21 | open_file_cache max=1000 inactive=5m; 22 | open_file_cache_valid 2m; 23 | open_file_cache_min_uses 1; 24 | open_file_cache_errors on; 25 | 26 | aio on; 27 | 28 | 29 | server { 30 | listen ${PORT}; 31 | server_name localhost; 32 | 33 | vod_mode remote; 34 | vod_upstream_location /remote; 35 | 36 | vod_metadata_cache metadata_cache 2048m; 37 | vod_response_cache response_cache 128m; 38 | vod_last_modified_types *; 39 | vod_segment_duration 9000; 40 | vod_align_segments_to_key_frames on; 41 | vod_dash_fragment_file_name_prefix "segment"; 42 | vod_hls_segment_file_name_prefix "segment"; 43 | 44 | vod_dash_absolute_manifest_urls off; 45 | vod_hls_absolute_master_urls off; 46 | vod_hls_absolute_index_urls off; 47 | 48 | vod_manifest_segment_durations_mode accurate; 49 | 50 | aws_access_key ${AWS_ACCESS_KEY}; 51 | aws_key_scope ${AWS_KEY_SCOPE}; 52 | aws_signing_key ${AWS_SIGNING_KEY}; 53 | aws_s3_bucket ${AWS_S3_BUCKET}; 54 | 55 | 56 | location ^~ /remote/dash/ { 57 | aws_sign; 58 | internal; 59 | rewrite ^/remote/dash/(.*) /$1 break; 60 | proxy_pass http://${AWS_S3_BUCKET}.s3.amazonaws.com; 61 | proxy_set_header Host ${AWS_S3_BUCKET}.s3.amazonaws.com; 62 | proxy_set_header x-amz-cf-id ""; # https://github.com/anomalizer/ngx_aws_auth/issues/6 63 | } 64 | 65 | location ^~ /remote/hls/ { 66 | aws_sign; 67 | internal; 68 | rewrite ^/remote/hls/(.*) /$1 break; 69 | proxy_pass http://${AWS_S3_BUCKET}.s3.amazonaws.com; 70 | proxy_set_header Host ${AWS_S3_BUCKET}.s3.amazonaws.com; 71 | proxy_set_header x-amz-cf-id ""; 72 | } 73 | 74 | location ^~ /remote/mss/ { 75 | aws_sign; 76 | internal; 77 | rewrite ^/remote/mss/(.*) /$1 break; 78 | proxy_pass http://${AWS_S3_BUCKET}.s3.amazonaws.com; 79 | proxy_set_header Host ${AWS_S3_BUCKET}.s3.amazonaws.com; 80 | proxy_set_header x-amz-cf-id ""; 81 | } 82 | 83 | location /hls/ { 84 | vod hls; 85 | add_header Access-Control-Allow-Headers '*'; 86 | add_header Access-Control-Allow-Origin '*'; 87 | add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; 88 | } 89 | 90 | location /dash/ { 91 | vod dash; 92 | add_header Access-Control-Allow-Headers '*'; 93 | add_header Access-Control-Allow-Origin '*'; 94 | add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; 95 | } 96 | 97 | location /mss/ { 98 | vod mss; 99 | add_header Access-Control-Allow-Headers '*'; 100 | add_header Access-Control-Allow-Origin '*'; 101 | add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; 102 | } 103 | } 104 | } 105 | 106 | 107 | -------------------------------------------------------------------------------- /scripts/generate_signing_key.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import argparse 5 | import base64 6 | import hashlib 7 | import hmac 8 | import sys 9 | from datetime import datetime 10 | 11 | def sign(key, val): 12 | return hmac.new(key, val.encode('utf-8'), hashlib.sha256).digest() 13 | 14 | def getSignatureKey(key, dateStamp, regionName, serviceName): 15 | kDate = sign(("AWS4" + key).encode("utf-8"), dateStamp) 16 | kRegion = sign(kDate, regionName) 17 | kService = sign(kRegion, serviceName) 18 | kSigning = sign(kService, "aws4_request") 19 | return kSigning 20 | 21 | def cmdline_parser(): 22 | parser = argparse.ArgumentParser(description="Generate AWS S3 signing key in it's base64 encoded form") 23 | parser.add_argument("-k", "--secret-key", required=True, help='The secret key generated using AWS IAM. Do not confuse this with the access key id') 24 | parser.add_argument("-r", "--region", required=True, help='The AWS region where this key would be used. Example: us-east-1') 25 | parser.add_argument("-s", "--service", help='The AWS service for which this key would be used. Example: s3') 26 | parser.add_argument("-d", "--date", help='The date on which this key is generated in yyyymmdd format') 27 | parser.add_argument("--no-base64", action='store_true', help='Disable output as a base64 encoded string. This NOT recommended') 28 | parser.add_argument("-v", "--verbose", action='store_true', help='Produce verbose output on stderr') 29 | return parser.parse_args() 30 | 31 | if __name__ == "__main__": 32 | args = cmdline_parser() 33 | verbose = args.verbose 34 | 35 | ymd = args.date 36 | if ymd is None: 37 | now = datetime.utcnow().date() 38 | ymd = '%04d%02d%02d' % (now.year, now.month, now.day) 39 | if verbose: 40 | print('The auto-selected date is %s' % ymd, file=sys.stderr) 41 | 42 | service = args.service 43 | if service is None: 44 | service = 's3' 45 | if verbose: 46 | print('The auto-selected service is %s' % service, file=sys.stderr) 47 | 48 | region = args.region 49 | signature = getSignatureKey(args.secret_key, ymd, region, service) 50 | 51 | if args.no_base64: 52 | signature = signature.decode('latin_1') 53 | else: 54 | signature = base64.b64encode(signature).decode('ascii') 55 | 56 | print(signature) 57 | print('%s/%s/%s/aws4_request' % (ymd, region, service)) 58 | -------------------------------------------------------------------------------- /scripts/generate_signing_key_heroku.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import argparse 5 | import base64 6 | import hashlib 7 | import hmac 8 | import sys 9 | from datetime import datetime 10 | import json 11 | import requests 12 | 13 | def sign(key, val): 14 | return hmac.new(key, val.encode('utf-8'), hashlib.sha256).digest() 15 | 16 | def getSignatureKey(key, dateStamp, regionName, serviceName): 17 | kDate = sign(("AWS4" + key).encode("utf-8"), dateStamp) 18 | kRegion = sign(kDate, regionName) 19 | kService = sign(kRegion, serviceName) 20 | kSigning = sign(kService, "aws4_request") 21 | return kSigning 22 | 23 | """ 24 | Updates the AWS_SIGNING_KEY and AWS_KEY_SCOPE env vars in Heroku. 25 | This is required as the signing keys are only valid for a week. 26 | """ 27 | def updateSignature(hk, signature, scope): 28 | url = "https://api.heroku.com/apps/vod/config-vars" 29 | payload = {'AWS_SIGNING_KEY': signature, 'AWS_KEY_SCOPE': scope} 30 | headers = { 31 | 'authorization': "Bearer " + hk, 32 | 'content-type': "application/json", 33 | 'accept': "application/vnd.heroku+json; version=3", 34 | } 35 | 36 | response = requests.patch(url, data=json.dumps(payload), headers=headers) 37 | return response.status_code 38 | 39 | def cmdline_parser(): 40 | parser = argparse.ArgumentParser(description="Generate AWS S3 signing key in it's base64 encoded form") 41 | parser.add_argument("-k", "--secret-key", required=True, help='The secret key generated using AWS IAM. Do not confuse this with the access key id') 42 | parser.add_argument("-hk", "--heroku-key", required=True, help='Heroku API Key') 43 | parser.add_argument("-r", "--region", required=True, help='The AWS region where this key would be used. Example: us-east-1') 44 | parser.add_argument("-s", "--service", help='The AWS service for which this key would be used. Example: s3') 45 | parser.add_argument("-d", "--date", help='The date on which this key is generated in yyyymmdd format') 46 | parser.add_argument("--no-base64", action='store_true', help='Disable output as a base64 encoded string. This NOT recommended') 47 | parser.add_argument("-v", "--verbose", action='store_true', help='Produce verbose output on stderr') 48 | return parser.parse_args() 49 | 50 | if __name__ == "__main__": 51 | args = cmdline_parser() 52 | verbose = args.verbose 53 | 54 | ymd = args.date 55 | if ymd is None: 56 | now = datetime.utcnow().date() 57 | ymd = '%04d%02d%02d' % (now.year, now.month, now.day) 58 | if verbose: 59 | print('The auto-selected date is %s' % ymd, file=sys.stderr) 60 | 61 | service = args.service 62 | if service is None: 63 | service = 's3' 64 | if verbose: 65 | print('The auto-selected service is %s' % service, file=sys.stderr) 66 | 67 | region = args.region 68 | signature = getSignatureKey(args.secret_key, ymd, region, service) 69 | 70 | if args.no_base64: 71 | signature = signature.decode('latin_1') 72 | else: 73 | signature = base64.b64encode(signature).decode('ascii') 74 | 75 | print('Updating signature...') 76 | scope = '%s/%s/%s/aws4_request' % (ymd, region, service) 77 | s = updateSignature(args.heroku_key, signature, scope) 78 | print(s) 79 | # print(signature) 80 | # print('%s/%s/%s/aws4_request' % (ymd, region, service)) 81 | --------------------------------------------------------------------------------