nginx and rtmp config

This is the first section of my nginx, rtmp, oauth live streaming server. As I described in my introduction page I wanted to live stream output from my GoPro, but as I do not have thousands of followers on YouTube and I avoid Facebook like the plague it left me with few other easy and affordablr options.

So I did it myself.

This series is an overview of my adventure in setting up my very own live streaming server. It includes a little of this, and a lot of that, but mostly it just works.

Seeing as I am also a little curious about security, I wanted the streams to be encrypted as well as access controlled…. all without spending any more money than I already do on my home network.

So lets get into it.

Section 1: The nginx config and how it controls access

The nginx config comprises two sections. The first section describes how incoming rtmp sessions are handled (the Producer), and the second section describes the user web server interface (the Consumer). For my project it was important to have access control and security associated with the Consumer to restrict access and encrypt the output streams. I was not as concerned as much about securing the incoming Producer streams since without access to the Consumer they do little other than take up some bandwidth. My users could watch those errant streams, but with a quick address filter in nginx I can stop them if I choose.

The Producer:

The nginx config file /etc/nginx/nginx.conf (for most people) has to include a new rtmp section:


rtmp {

    access_log /var/log/nginx/input_stream.log;

    server {
        listen 1935;

        # Publish endpoint
        #
        # A stream is published as rtmp://YOUR_DOMAIN/live/CHOSEN_STREAM_NAME
        # To access this location, port 1935 must be open
        # CHOSEN_STREAM_NAME is selected by the stream source, such as in the GoPro
        #
        # Example: 
        #
        #     rtmp://example.com/live/GoPro_Stream
        #
        application live {
            live on;

            # No RTMP playback
            deny play all;

            # Push this stream to the local HLS packaging application
            # defined below
            push rtmp://127.0.0.1:1935/hls;

            # When a stream starts, announce it to the Python application
            # In this case the app is listening on port 8000.
            on_publish http://127.0.0.1:8000/start_stream;

            # When a stream stops, tell the Python application.  That way it
            # can update its list of active streams
            on_publish_done http://127.0.0.1:8000/stop_stream;

            # If you also want to keep a permanent copy of the stream in another folder, uncomment the following:
            # record all;
            # record_suffix _recorded.flv;
            # record_path /opt/streams/recordings;
            # record_unique on;
        }

        # The hls application is responsible for packaging up the stream segments and
        # generating the decryption keys
        application hls {
            live on;

            # No RTMP playback
            deny play all;

            # Only allow publishing from localhost, i.e. the above live application
            allow publish 127.0.0.1;
            deny publish all;
            deny play all;

            # Package this stream as HLS
            hls on;
            hls_path /opt/streams/live/;

            # Put streams in their own subdirectory under 'hls_path'
            hls_nested on;
            hls_fragment_naming system;

            # Encrypt MPEG-TS segments.
            #
            # Every 1 minute of video will require a new decryption key.
            #
            # Put keys under their own 'hls_key_path' folder
            #
            # Also, when nginx generates the m3u8 index, specify the URL path of /keys/.
            # This tells the client player where to request the decryption keys.
            #
            hls_keys on;
            hls_key_path /opt/streams/keys/;
            hls_fragments_per_key 6;
            hls_key_url /keys/;
        }
    }
}


So in the rtmp section there are two applications defined, live and hls. hls is only accessible as an internal path from the live application.

Following through the config, a Producer enters the rtmp URL into their device, such as rtmp://example.com/live/Go_Pro_Stream (note the underscores - nginx does not like stream names with spaces). When the stream starts it notifies the Python application via the on_publish URL (more on that in a later section) that a new stream has started. Similarly when the stream stops, nginx notifies the application via the on_publish_done URL that the stream has stopped. For now, it is possible to comment out the on_publish and on_publish_done until an application is ready to manage the connections.

When the stream is ongoing, nginx takes the incoming data and pushes it through a localhost URL to the hls application. Access to the hls application is restricted to the previous live application via localhost connections only. hls will now package up the incoming stream into MGET Transprt Stream (.ts) segments stored in the hls_path folder with the stream name added to the folder. For example, using my above config, if my stream name was Go_Pro_Stream, the segments will be written to the /opt/streams/live/Go_Pro_Streams folder. Make sure the nginx process has permission to write in the hls_path folder.

The generated keys needed by client player to decrypt the streams will be located under the hls_key_path folder with the stream name added to the path. As in my example above, this will be the /opt/streams/keys/Go_Pro_Streams folder.

When a stream is being produced the contents of the hls_path segment folder will look similar to:


-rw-r--r-- 1 www-data www-data  2.1M Jun 30 14:10 1561929002908.ts
-rw-r--r-- 1 www-data www-data  1.5M Jun 30 14:10 1561929008540.ts
-rw-r--r-- 1 www-data www-data  1.5M Jun 30 14:10 1561929013547.ts
-rw-r--r-- 1 www-data www-data  2.2M Jun 30 14:10 1561929018542.ts
-rw-r--r-- 1 www-data www-data  1.9M Jun 30 14:10 1561929024251.ts
-rw-r--r-- 1 www-data www-data  1.3M Jun 30 14:10 1561929029238.ts
-rw-r--r-- 1 www-data www-data  1.8M Jun 30 14:10 1561929035338.ts
-rw-r--r-- 1 www-data www-data  2.1M Jun 30 14:10 1561929040337.ts
-rw-r--r-- 1 www-data www-data  2.7M Jun 30 14:10 1561929046675.ts
-rw-r--r-- 1 www-data www-data 1014K Jun 30 14:10 1561929053170.ts
-rw-r--r-- 1 www-data www-data  1.5M Jun 30 14:11 1561929058173.ts
-rw-r--r-- 1 www-data www-data  965K Jun 30 14:11 1561929064630.ts
-rw-r--r-- 1 www-data www-data   485 Jun 30 14:11 index.m3u8


Since the stream is “live” and I am not preserving the stream, there may only be between a dozen and two dozen files. As more segments are created, eventually the old segments are not needed anymore and are removed. This keeps the total size and number of these files to a minimum.

Similarly the content of the hls_key_path key folder will look similar to:


-rw-r--r-- 1 www-data www-data   16 Jun 30 14:10 1561928986743.key
-rw-r--r-- 1 www-data www-data   16 Jun 30 14:10 1561929018542.key
-rw-r--r-- 1 www-data www-data   16 Jun 30 14:11 1561929053170.key
-rw-r--r-- 1 www-data www-data   16 Jun 30 14:11 1561929085873.key


Without going too deep into RTMP, the main file describing what to fetch to stream the video is the index.m3u8 file. In it is the list of available transport stream segments, the .ts files, as well the keys needed to decrypt the different transport stream segments. For example, an index.m3u8 might look like:


#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:164
#EXT-X-TARGETDURATION:6
#EXT-X-KEY:METHOD=AES-128,URI="/keys/Go_Pro_Stream/1561929216707.key",IV=0x00000000000000000000016BAA3C72C3
#EXTINF:5.833,
1561929226870.ts
#EXTINF:5.000,
1561929232704.ts
#EXTINF:5.833,
1561929237706.ts
#EXTINF:5.634,
1561929243543.ts
#EXT-X-KEY:METHOD=AES-128,URI="/keys/Go_Pro_Stream/1561929249177.key",IV=0x00000000000000000000016BAA3CF199
#EXTINF:5.300,
1561929249177.ts
#EXTINF:5.000,
1561929254480.ts


In a nutshell, the sequence says to load key 1561929216707.key which will then be used to decrypt the next four segments 1561929226870.ts, 1561929232704.ts, 1561929237706.ts, and 1561929243543.ts. Then the key switches to 1561929249177.key which is used to decrypt the next two segments 1561929249177.ts and 1561929254480.ts.

As nginx continues to receive new data and create new encrypted transport stream segment files, the index.m3u8 is continuously updated with the new information. When the web player starts running low on data to playback, it will request a new copy of the index.m3u8 file to learn what the new segment names are and which keys are used to decrypt them.

These are all generated by nginx so you don’t need to be too concerned about them. What is important is that any request to download these files needs to be controlled by the Consumer configuration of nginx.

The Consumer:

The Consumer is the front-end web page definition for nginx and is where all the security happens. For now this will be simply an explanation of its function as there is no OAuth enabled application receiving its requests yet. That will be discussed later.

To provide the necessary security though a few additional modules need to be added to nginx that are not included in most distributions, which unfortunately means building your own nginx and running it instead of the version included with your OS. This is not terribly difficult but does require your system be setup to build nginx. More on that though in the next section.

The confguration file might be named /etc/nginx/sites-enabled/stream and be appropriately soft-linked into the /etc/nginx/sites-enabled folder.

For example:


#######################################
#
# S T R E A M   S E R V E R
#
#######################################

# The python application used to control access.
# If more than one server running the application to handle enterprise levels of load,
# use round-robin access by including additional servers in this list.
upstream stream_backend {
    server 127.0.0.1:8000;
}

# Who uses port 80 for anything anymore?????????? Get rid of it.
server {
    server_name EXTERNALLY_VISIBLE_STREAM_HOSTNAME;
    listen 80;
    access_log /var/log/nginx/stream_consumer.log;
    location / {
            return 301 https://EXTERNALLY_VISIBLE_STREAM_HOSTNAME$request_uri;
    }
}

# The main server
server {

    server_name EXTERNALLY_VISIBLE_STREAM_HOSTNAME;
    listen 443 ssl http2;
    fastcgi_param            HTTPS on;
    ssl_certificate          PATH_TO_YOUR_LETSENCRYPT_FREE_SSL_FULLCHAIN_CERT.pem;
    ssl_certificate_key      PATH_TO_YOUR_LETSENCRYPT_FREE_SSL_KEY.key;

    # Just in case you mix domains
    add_header 'Access-Control-Allow-Origin' '*';

    root /opt/streams;

    access_log /var/log/nginx/stream_consumer.log;

    # MPEG-TS segments can be cached upstream indefinitely
    location ~ ^/.+\.ts$ {
        expires max;
    }

    # client is requesting to download a key file.
    # Set nginx variables $stream_name from path and $user_sig from URL parameter 's'.
    # The URL parameters are not in the default index.m3u8, but rather inserted by
    # nginx when downloaded to the client (more lower down)
    location ~ ^/keys/([^/]+)/[0-9]+\.key$ {
        set $stream_name $1;
        set $user_sig $arg_s;

        # Call internal URL /check_key_authorization to check if user is allowed to
        # download the key.  If they are not this will return a 403 (Forbidden)
        # if not authorized to downoad the file.  If the client is authorized,
        # the file is downloaded.
        auth_request /check_key_authorization;
    }

    # internal access only request to check authorization for the key
    # this will  both verify request came from valid client using hash
    # and it will verify users credentials with backend application.
    location = /check_key_authorization {
        internal;

        # Generate the hash based on their session id, stream name and secret key.
        # This should match what was generated earlier and provided in the 
        # index.m3u8 file and stored as $user_sig.
        #
        # The sec_hmac_sha1 and set_encode_base64 functions are NOT part of 
        # the standard nginx distribution.

        set_hmac_sha1 $sig "SOME_SUPER_SECRET_STRING" "$cookie_sessionid $stream_name";
        set_encode_base64 $sig $sig;

        # first, do the hashes match?
        if ($sig != $user_sig) {
            # imposter!!!!
            return 403;
        }

        # The hashes match, but we still need to validate the user.
        # Make a request to the backend application to authorize this
        # key.  If the user credentials are valid, this returns 200 OK,
        # otherwise the 401 error is passed back to the above key request
        # and access to the key is denied.

        proxy_set_header X-Stream-Name $stream_name;
        proxy_pass http://stream_backend/authorize_key;
    }

    # Serve the video m3u8 file, and perform some "magic" to generate the
    # URL parameters used to validate the key requests on the fly.
    #
    # The stream name is inferred from the URL

    location ~ ^/live/([^/]+)/index\.m3u8$ {

        expires -1d;
        add_header Cache-Control no-cache;

        # set the stream name
        set $stream_name $1;

        # Generate signature based on their session id
        set_hmac_sha1 $sig "SOME_SUPER_SECRET_STRING" "$cookie_sessionid $stream_name";
        set_encode_base64 $sig $sig;

        # Inline modify the index.m3u8 response to include the hash as a parameter
        # to the key URL.  This parameter will be returned with the /key request URL 
        # made by the client.  The hash can then be checked to verify the request came
        # form the client that was sent this unique index.m3u8 file.
        subs_filter_types application/vnd.apple.mpegurl;
        subs_filter "URI=\"/keys/([^/]+)/([0-9]+)\.key\"" "URI=\"/keys/$1/$2.key?s=$sig\"" gir;
    }


    # Serving the website
    # All other requests direct to the web application
    location / {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_redirect off;
        proxy_set_header Host $host;
        proxy_pass http://stream_backend/;
    }
}


The files the client fundamentally will request include:

  1. The index.m3u8 manifest file
  2. The .key files
  3. The .ts transport stream files
  4. Other web pages to run the web application

The magic here is to modify the contents of the index.m3u8 file uniquely for each user and use that uniqueness to verify that this user is allowed to access the files. This part I gleaned from Ben Wilber’s tutorial which proved very helpful in understanding how to secure the key files.

When the index.m3u8 file is requested the nginx configuration will inline modify the URLs pointing to the keys and add a hash parameter to each URL. When the client submits new requests for a key, the parameter is also submitted. The nginx server can then recompute the hash to verify this request originated from the same client. Someone else sending the same URL with the same hash parameter would cause the nginx server to generate a different hash and thus be detected as an imposter. This primarily stops someone different from replaying the stream. However another step is taken to actually authenticate the user’s crednetials through the web application as well before authorizing the user to have access to the decryption key file. This step depends upon your prefered back-end authorization. Ben Wilber chose django, a decent all around backend database and user management architecture, but I already had OAuth working with my cloud server, so I chose to go the OAuth route.

Validating the user access with nginx though requires a few additional modules not included in the base distribution, which I will discuss in a later section. For now, asusming those modules are working, the magic in validating the user request involves first generating a hash of their session identity, a secret string and the stream name and base64 encoding it in the $sig parameter:


# Generate signature based on their session id
set_hmac_sha1 $sig "SOME_SUPER_SECRET_STRING" "$cookie_sessionid $stream_name";
set_encode_base64 $sig $sig;


Then as the index.m3u8 file is downloaded, modify the key URL to add the $sig parameter to the URL using:


subs_filter_types application/vnd.apple.mpegurl;
subs_filter "URI=\"/keys/([^/]+)/([0-9]+)\.key\"" "URI=\"/keys/$1/$2.key?s=$sig\"" gir;


This results in modifying the index.m3u8 file that is on disk to instead include something like the following when received by the client:


#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:882
#EXT-X-TARGETDURATION:6
#EXT-X-KEY:METHOD=AES-128,URI="/keys/Go_Pro_Stream/1561933121165.key?s=dKiMvdm4GbMuhiAycFcrXjxf+78=",IV=0x00000000000000000000016BAA78068D
#EXTINF:5.000,
1561933121165.ts
#EXTINF:5.000,
1561933126169.ts
#EXTINF:5.833,
1561933131168.ts
#EXTINF:5.000,
1561933137007.ts
#EXTINF:5.000,
1561933142007.ts
#EXTINF:6.433,
1561933147003.ts


Notice how the URI for the key now appears with the parameter ?s=dKiMvdm4GbMuhiAycFcrXjxf+78= in the above example. This was the encoded hash added by nginx that is used to verify the request for the key came from the same system that downloaded the index.m3u8 file.

Magic.

This does not complete the authorization though. That occurs in the line proxy_pass http://stream_backend/authorize_key; and will be covered in a later section on the python application.

I do hope this proves useful to someone out there.

  • Introduction: What this project is about
  • Section 1: The nginx config and how it controls access <== YOU ARE HERE
  • Section 2: Dealing with the missing nginx pieces (Coming Soon)
  • Section 3: Designing an application to glue it together (Coming Soon)
  • Section 4: Integrating OAuth authentication using Nextcloud (Coming Soon)
  • Section 5: Session storage with Redis (cool Enterprise scaling option) (Coming Soon)
  • Section 6: Streaming with my GoPro or with ffmpeg (Coming Soon)

In the next section I will discuss adding the missing nginx modules.