'
49 | '\n'
50 | )
51 | print('Environment Variables (can be used instead of command arguments):')
52 | print(' RTMP_SERVER_UPCLOUD_USER : UpCloud user name to authenticate with. Can replace \'-u\' option.')
53 | print(' RTMP_SERVER_UPCLOUD_PASSWORD : UpCloud user password to authenticate with. Can replace \'-p\' option.')
54 | print(' RTMP_SERVER_UPCLOUD_REGION : UpCloud region to create server in. Defaults to \'us-sjo1\'.')
55 | print(' RTMP_SERVER_UPCLOUD_NODE_CORES : The number cores for the UpCloud server. Defaults to \'4\'')
56 | print(' RTMP_SERVER_UPCLOUD_NODE_RAM : The amount of RAM in MB for the UpCloud server. Defaults to \'4096\'')
57 | print(' RTMP_SERVER_UPCLOUD_NODE_DISK : The amount of disk space in GB for the UpCloud server. Defaults to \'64\'')
58 |
59 | # Default values for config
60 | upcloud_user = None \
61 | if 'RTMP_SERVER_UPCLOUD_USER' not in os.environ \
62 | else os.environ['RTMP_SERVER_UPCLOUD_USER']
63 | upcloud_password = None \
64 | if 'RTMP_SERVER_UPCLOUD_PASSWORD' not in os.environ \
65 | else os.environ['RTMP_SERVER_UPCLOUD_PASSWORD']
66 | upcloud_region = up.ZONE.SanJose \
67 | if 'RTMP_SERVER_UPCLOUD_REGION' not in os.environ \
68 | else os.environ['RTMP_SERVER_UPCLOUD_REGION']
69 | upcloud_cores = 4 \
70 | if 'RTMP_SERVER_UPCLOUD_NODE_CORES' not in os.environ \
71 | else int(os.environ['RTMP_SERVER_UPCLOUD_NODE_CORES'])
72 | upcloud_ram = 4096 \
73 | if 'RTMP_SERVER_UPCLOUD_NODE_RAM' not in os.environ \
74 | else int(os.environ['RTMP_SERVER_UPCLOUD_NODE_RAM'])
75 | upcloud_disk = 64 \
76 | if 'RTMP_SERVER_UPCLOUD_NODE_DISK' not in os.environ \
77 | else int(os.environ['RTMP_SERVER_UPCLOUD_NODE_DISK'])
78 | rtmp_config_filepath = None
79 | ssh_key_filepath = '~/.ssh/id_rsa.pub'
80 | rtmp_streaming_password = None
81 |
82 | # fetch CLI arguments
83 | try:
84 | opts, args = getopt.getopt(sys.argv[1:],"u:p:c:k:s:h")
85 | except getopt.GetoptError as err:
86 | print(err)
87 | print_help()
88 | exit(2)
89 |
90 | for opt, arg in opts:
91 | if opt == '-u':
92 | upcloud_user = arg
93 | elif opt == '-p':
94 | upcloud_password = arg
95 | elif opt == '-c':
96 | rtmp_config_filepath = arg
97 | elif opt == '-k':
98 | ssh_key_filepath = arg
99 | elif opt == '-s':
100 | rtmp_streaming_password = arg
101 | elif opt == '-h':
102 | print_help()
103 | exit(0)
104 | else:
105 | print('ERROR - Got on unrecognized command line option "{0}"'.format(opt))
106 | exit(2)
107 |
108 | if upcloud_user is None \
109 | or upcloud_password is None \
110 | or rtmp_config_filepath is None \
111 | or ssh_key_filepath is None \
112 | or rtmp_streaming_password is None:
113 | print('ERROR - configuration is not complete.')
114 | exit(2)
115 |
116 | # Connect to UpCloud
117 | manager = up.CloudManager(upcloud_user, upcloud_password, timeout=60)
118 | manager.authenticate()
119 | print('Connected to UpCloud API as user "{0}"'.format(upcloud_user))
120 |
121 | res = subprocess.run(
122 | 'cat {0}'.format(ssh_key_filepath),
123 | shell=True,
124 | stdout=subprocess.PIPE,
125 | text=True,
126 | )
127 | ssh_key_value = res.stdout.strip()
128 | rtmp_user_desc = up.login_user_block(
129 | username='rtmpserver',
130 | ssh_keys=[ssh_key_value],
131 | create_password=False,
132 | )
133 |
134 | rtmp_server_desc = up.Server(
135 | core_number=upcloud_cores, # CPU cores
136 | memory_amount=upcloud_ram, # RAM in MB
137 | zone=upcloud_region,
138 | title='Multiservice RTMP Broadcaster',
139 | # UpCloud strangely requires that every server have a qualified domain
140 | # name. (?!) Using a totaly made up name here.
141 | hostname='multiservice-rtmp-server.com',
142 | storage_devices=[
143 | up.Storage(os='Ubuntu 18.04', size=upcloud_disk ),
144 | ],
145 | login_user=rtmp_user_desc, # user and ssh-keys
146 | user_data=INIT_SCRIPT,
147 | )
148 |
149 | print('Starting creation of server with these parameters:')
150 | print(
151 | ' cores = {0}\n'
152 | ' RAM = {1} MB\n'
153 | ' disk = {2} GB\n'
154 | ' region = {3}'.format(
155 | upcloud_cores, upcloud_ram, upcloud_disk, upcloud_region
156 | )
157 | )
158 | rtmp_server = manager.create_server(rtmp_server_desc)
159 | ip_addr = rtmp_server.get_ip()
160 | print(
161 | 'Server creation done.\n'
162 | ' server = {0}\n'
163 | ' IP address = {1}'.format(rtmp_server, ip_addr)
164 | )
165 |
166 | # wait for the server to finish booting and installing docker
167 | print('Waiting 8 minutes for server set up to complete ...')
168 | time.sleep(60)
169 | print('Waiting 7 minutes for server set up to complete ...')
170 | time.sleep(60)
171 | print('Waiting 6 minutes for server set up to complete ...')
172 | time.sleep(60)
173 | print('Waiting 5 minutes for server set up to complete ...')
174 | time.sleep(60)
175 | print('Waiting 4 minutes for server set up to complete ...')
176 | time.sleep(60)
177 | print('Waiting 3 minutes for server set up to complete ...')
178 | time.sleep(60)
179 | print('Waiting 2 minutes for server set up to complete ...')
180 | time.sleep(60)
181 | print('Waiting 1 minutes for server set up to complete ...')
182 | time.sleep(60)
183 |
184 | # Send configuration to server and launch Docker image
185 | print('Adding IP address {0} to known hosts ...'.format(ip_addr))
186 | res = subprocess.run('ssh-keygen -R {}'.format(ip_addr), shell=True)
187 | if res.returncode != 0:
188 | print('ERROR when removing server IP from known hosts.\n {0}'.format(res.stderr))
189 | exit(1)
190 | elif res.stdout is not None:
191 | print(res.stdout)
192 | res = subprocess.run('ssh-keyscan -T 240 {0} >> ~/.ssh/known_hosts'.format(ip_addr), shell=True)
193 | if res.returncode != 0:
194 | print('ERROR when adding server IP to known hosts.\n {0}'.format(res.stderr))
195 | exit(1)
196 | elif res.stdout is not None:
197 | print(res.stdout)
198 |
199 | # send configuration file to Server
200 | scp_cmd = 'scp \'{0}\' rtmpserver@{1}:/home/rtmpserver/rtmp_server_config.json'.format(
201 | rtmp_config_filepath,
202 | ip_addr
203 | )
204 | print('Sending configuration file to serverr with: {0}'.format(scp_cmd))
205 | res = subprocess.run(
206 | scp_cmd,
207 | shell=True
208 | )
209 | if res.returncode != 0:
210 | print(
211 | 'ERROR when sending RTMP configuration to server.\n'
212 | ' returncode = {0}\n stderr = {1}'.format(res.returncode, res.stderr)
213 | )
214 | exit(1)
215 | elif res.stdout is not None:
216 | print(res.stdout)
217 |
218 | # start the docker subprocess
219 | res = subprocess.run(
220 | 'ssh rtmpserver@{0} '
221 | '"docker run -d -p 80:80 -p 1935:1935 '
222 | '--env MULTISTREAMING_PASSWORD={1} '
223 | '-v /home/rtmpserver/rtmp_server_config.json:/rtmp-configuration.json '
224 | 'kamprath/multistreaming-server:latest"'.format(
225 | ip_addr, rtmp_streaming_password
226 | ),
227 | shell=True,
228 | stdout=subprocess.PIPE,
229 | text=True,
230 | )
231 | docker_container_id = res.stdout[:12]
232 | print('Started Docker container: {0}'.format(docker_container_id))
233 |
234 | # Finished
235 | print('Finished!\n')
236 | print('The IP address for the Multistreaming Server is:')
237 | print(' {0}\n'.format(ip_addr))
238 | print('Visit the Multistreaming Server\'s statistics page here:')
239 | print(' http://{0}/stat\n'.format(ip_addr))
240 | print('Use this command to log into the server (if needed):')
241 | print(' ssh rtmpserver@{0}\n'.format(ip_addr))
242 | print('When done, terminate this server in the UpCloud Web Console here:')
243 | print(' https://hub.upcloud.com/\n')
244 |
--------------------------------------------------------------------------------
/multistreaming-server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM jrottenberg/ffmpeg:5-alpine
2 | MAINTAINER Michael Kamprath "https://github.com/michaelkamprath"
3 |
4 | ARG NGINX_VERSION=1.20.0
5 | ARG RTMP_REPO=uizaio
6 | ARG RTMP_MODULE_VERSION=1.4.0.4
7 | ARG TINI_VERSION=v0.19.0
8 | ARG SUPERVISORD_VERSION=4.2.4
9 | ARG PIPENV_PACKAGE_VERSION=2022.1.8
10 |
11 | RUN set -x \
12 | && addgroup -S stunnel \
13 | && adduser -S -D -H -h /dev/null -s /sbin/nologin -G stunnel -g stunnel stunnel \
14 | && echo "//dl-cdn.alpinelinux.org/alpine/latest-stable/main/x86_64/" >> /etc/apk/repositories \
15 | && apk update \
16 | && apk add --no-cache --update stunnel ca-certificates \
17 | && apk add --no-cache pcre openssl stunnel gettext python3 py3-pip \
18 | && apk add --no-cache --virtual build-deps build-base pcre-dev openssl-dev zlib zlib-dev wget make \
19 | && wget -O nginx-${NGINX_VERSION}.tar.gz http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz \
20 | && tar -zxvf nginx-${NGINX_VERSION}.tar.gz \
21 | && wget -O nginx-rtmp-module-${RTMP_MODULE_VERSION}.tar.gz https://github.com/${RTMP_REPO}/nginx-rtmp-module/archive/${RTMP_MODULE_VERSION}.tar.gz \
22 | && tar -zxvf nginx-rtmp-module-${RTMP_MODULE_VERSION}.tar.gz \
23 | && cd nginx-${NGINX_VERSION} \
24 | && export CFLAGS=-Wno-error \
25 | && ./configure --with-http_ssl_module --add-module=../nginx-rtmp-module-${RTMP_MODULE_VERSION} \
26 | && make \
27 | && make install \
28 | && cp /nginx-rtmp-module-${RTMP_MODULE_VERSION}/stat.xsl /usr/local/nginx/html/ \
29 | && apk del build-deps \
30 | && mkdir -p /var/www/html/recordings \
31 | && mkdir -p /var/run/stunnel/ \
32 | && chown nobody:nobody -R /var/www/html \
33 | && chown stunnel:stunnel /var/run/stunnel/ \
34 | && wget -O /tini https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static \
35 | && chmod +x /tini \
36 | && pip3 install --ignore-installed distlib supervisor==${SUPERVISORD_VERSION} pipenv==${PIPENV_PACKAGE_VERSION}
37 |
38 | COPY Pipfile Pipfile.lock /
39 | RUN pipenv install --system --deploy
40 |
41 | COPY supervisord.conf /etc/supervisor/supervisord.conf
42 | COPY stunnel-conf/etc-default-stunnel /etc/default/stunnel
43 | COPY stunnel-conf/etc-stunnel-conf.d-fb.conf /etc/stunnel/conf.d/fb.conf
44 | COPY stunnel-conf/etc-stunnel-conf.d-ig.conf /etc/stunnel/conf.d/ig.conf
45 | COPY stunnel-conf/etc-stunnel-stunnel.conf /etc/stunnel/stunnel.conf
46 |
47 | COPY index.html /usr/local/nginx/html/
48 | COPY nginx-conf/nginx.conf /base-nginx.conf
49 | COPY launch-nginx-server.sh launch-nginx-server.sh
50 | COPY rtmp-conf-generator.py nginx-template.conf.j2 /
51 | RUN touch /rtmp-configuration.json && chmod 744 /rtmp-configuration.json
52 |
53 | EXPOSE 1935
54 | EXPOSE 80
55 |
56 | STOPSIGNAL SIGTERM
57 | ENTRYPOINT ["/tini", "--"]
58 | CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
59 |
--------------------------------------------------------------------------------
/multistreaming-server/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | name = "pypi"
3 | url = "https://pypi.org/simple"
4 | verify_ssl = true
5 |
6 | [dev-packages]
7 | pylint = "*"
8 | black = "*"
9 |
10 | [packages]
11 | jinja2 = "*"
12 |
13 | [requires]
14 | python_version = "3.12"
15 |
16 | [pipenv]
17 | allow_prereleases = true
18 |
--------------------------------------------------------------------------------
/multistreaming-server/Pipfile.lock:
--------------------------------------------------------------------------------
1 | {
2 | "_meta": {
3 | "hash": {
4 | "sha256": "999482fdcf4ef4a48be7cfc160df6000b69546a4c9d77be01e8c5b1b4e23be77"
5 | },
6 | "pipfile-spec": 6,
7 | "requires": {
8 | "python_version": "3.12"
9 | },
10 | "sources": [
11 | {
12 | "name": "pypi",
13 | "url": "https://pypi.org/simple",
14 | "verify_ssl": true
15 | }
16 | ]
17 | },
18 | "default": {
19 | "jinja2": {
20 | "hashes": [
21 | "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb",
22 | "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"
23 | ],
24 | "index": "pypi",
25 | "markers": "python_version >= '3.7'",
26 | "version": "==3.1.5"
27 | },
28 | "markupsafe": {
29 | "hashes": [
30 | "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4",
31 | "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30",
32 | "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0",
33 | "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9",
34 | "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396",
35 | "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13",
36 | "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028",
37 | "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca",
38 | "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557",
39 | "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832",
40 | "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0",
41 | "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b",
42 | "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579",
43 | "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a",
44 | "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c",
45 | "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff",
46 | "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c",
47 | "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22",
48 | "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094",
49 | "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb",
50 | "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e",
51 | "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5",
52 | "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a",
53 | "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d",
54 | "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a",
55 | "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b",
56 | "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8",
57 | "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225",
58 | "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c",
59 | "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144",
60 | "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f",
61 | "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87",
62 | "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d",
63 | "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93",
64 | "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf",
65 | "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158",
66 | "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84",
67 | "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb",
68 | "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48",
69 | "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171",
70 | "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c",
71 | "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6",
72 | "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd",
73 | "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d",
74 | "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1",
75 | "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d",
76 | "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca",
77 | "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a",
78 | "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29",
79 | "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe",
80 | "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798",
81 | "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c",
82 | "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8",
83 | "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f",
84 | "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f",
85 | "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a",
86 | "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178",
87 | "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0",
88 | "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79",
89 | "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430",
90 | "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"
91 | ],
92 | "markers": "python_version >= '3.9'",
93 | "version": "==3.0.2"
94 | }
95 | },
96 | "develop": {
97 | "astroid": {
98 | "hashes": [
99 | "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a",
100 | "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25"
101 | ],
102 | "markers": "python_full_version >= '3.8.0'",
103 | "version": "==3.2.4"
104 | },
105 | "black": {
106 | "hashes": [
107 | "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6",
108 | "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e",
109 | "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f",
110 | "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018",
111 | "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e",
112 | "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd",
113 | "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4",
114 | "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed",
115 | "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2",
116 | "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42",
117 | "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af",
118 | "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb",
119 | "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368",
120 | "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb",
121 | "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af",
122 | "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed",
123 | "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47",
124 | "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2",
125 | "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a",
126 | "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c",
127 | "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920",
128 | "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"
129 | ],
130 | "index": "pypi",
131 | "markers": "python_version >= '3.8'",
132 | "version": "==24.8.0"
133 | },
134 | "click": {
135 | "hashes": [
136 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28",
137 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"
138 | ],
139 | "markers": "python_version >= '3.7'",
140 | "version": "==8.1.7"
141 | },
142 | "dill": {
143 | "hashes": [
144 | "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca",
145 | "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"
146 | ],
147 | "markers": "python_version >= '3.11'",
148 | "version": "==0.3.8"
149 | },
150 | "isort": {
151 | "hashes": [
152 | "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109",
153 | "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"
154 | ],
155 | "markers": "python_full_version >= '3.8.0'",
156 | "version": "==5.13.2"
157 | },
158 | "mccabe": {
159 | "hashes": [
160 | "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325",
161 | "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"
162 | ],
163 | "markers": "python_version >= '3.6'",
164 | "version": "==0.7.0"
165 | },
166 | "mypy-extensions": {
167 | "hashes": [
168 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d",
169 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"
170 | ],
171 | "markers": "python_version >= '3.5'",
172 | "version": "==1.0.0"
173 | },
174 | "packaging": {
175 | "hashes": [
176 | "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002",
177 | "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"
178 | ],
179 | "markers": "python_version >= '3.8'",
180 | "version": "==24.1"
181 | },
182 | "pathspec": {
183 | "hashes": [
184 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08",
185 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"
186 | ],
187 | "markers": "python_version >= '3.8'",
188 | "version": "==0.12.1"
189 | },
190 | "platformdirs": {
191 | "hashes": [
192 | "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907",
193 | "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"
194 | ],
195 | "markers": "python_version >= '3.8'",
196 | "version": "==4.3.6"
197 | },
198 | "pylint": {
199 | "hashes": [
200 | "sha256:02f4aedeac91be69fb3b4bea997ce580a4ac68ce58b89eaefeaf06749df73f4b",
201 | "sha256:1b7a721b575eaeaa7d39db076b6e7743c993ea44f57979127c517c6c572c803e"
202 | ],
203 | "index": "pypi",
204 | "markers": "python_full_version >= '3.8.0'",
205 | "version": "==3.2.7"
206 | },
207 | "tomlkit": {
208 | "hashes": [
209 | "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde",
210 | "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"
211 | ],
212 | "markers": "python_version >= '3.8'",
213 | "version": "==0.13.2"
214 | }
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/multistreaming-server/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Multistream Server
6 | This service is being used to rebroadcast livestreams to multiple services.
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/multistreaming-server/launch-nginx-server.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # set the password from environment variable
4 | export DOLLAR='$'
5 | envsubst < base-nginx.conf > /usr/local/nginx/conf/nginx.conf
6 |
7 | # append nginx conf with RTMP Configuration
8 | python3 /rtmp-conf-generator.py /rtmp-configuration.json /nginx-template.conf.j2 >> /usr/local/nginx/conf/nginx.conf
9 | if [ $? -ne 0 ]; then
10 | echo "ERROR encountered when generating RTMP configuration."
11 | exit 1
12 | fi
13 |
14 | # finally, launch nginx
15 | /usr/local/nginx/sbin/nginx -g "daemon off;"
16 |
--------------------------------------------------------------------------------
/multistreaming-server/nginx-conf/nginx.conf:
--------------------------------------------------------------------------------
1 | worker_processes 1;
2 |
3 | events {
4 | worker_connections 1024;
5 | }
6 |
7 | http {
8 | include mime.types;
9 | default_type application/octet-stream;
10 |
11 | #log_format main '$remote_addr - $remote_user [$time_local] "$request" '
12 | # '$status $body_bytes_sent "$http_referer" '
13 | # '"$http_user_agent" "$http_x_forwarded_for"';
14 |
15 | access_log /dev/stdout;
16 |
17 | sendfile on;
18 | #tcp_nopush on;
19 |
20 | #keepalive_timeout 0;
21 | keepalive_timeout 65;
22 |
23 | #gzip on;
24 |
25 | server {
26 | listen 80;
27 | server_name 127.0.0.1;
28 |
29 | #charset koi8-r;
30 |
31 | #access_log logs/host.access.log main;
32 |
33 | location / {
34 | root html;
35 | index index.html index.htm;
36 | }
37 |
38 | location /auth {
39 | if (${DOLLAR}arg_pwd = '${MULTISTREAMING_PASSWORD}') {
40 | return 200;
41 | }
42 | return 401;
43 | }
44 |
45 | # This URL provides RTMP statistics in XML
46 | location /stat {
47 | rtmp_stat all;
48 |
49 | # Use this stylesheet to view XML as web page
50 | # in browser
51 | rtmp_stat_stylesheet stat.xsl;
52 | }
53 |
54 | location /stat.xsl {
55 | # XML stylesheet to view RTMP stats.
56 | # Copy stat.xsl wherever you want
57 | # and put the full directory path here
58 | root /usr/local/nginx/html/;
59 | }
60 |
61 |
62 | #error_page 404 /404.html;
63 |
64 | # redirect server error pages to the static page /50x.html
65 | #
66 | error_page 500 502 503 504 /50x.html;
67 | location = /50x.html {
68 | root html;
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/multistreaming-server/nginx-template.conf.j2:
--------------------------------------------------------------------------------
1 | {% if nginx_error_log %} error_log /dev/stderr debug; {% endif %}
2 |
3 | rtmp {
4 | server {
5 | listen 1935;
6 | chunk_size 4096;
7 | notify_method get;
8 |
9 | application {{ endpoint_name }} {
10 | on_publish http://127.0.0.1/auth;
11 | live on;
12 | record {{ record_mode }};
13 | record_path /var/www/html/recordings;
14 | record_unique on;
15 |
16 | # Define the applications to which the stream will be pushed, comment them out to disable the ones not needed:
17 | {% for name in transcode_configs.keys() %}
18 | push rtmp://127.0.0.1:1935/transcode_{{name}};
19 | {% endfor %}
20 | {% for application_endpoint in push_only_applications %}
21 | push rtmp://127.0.0.1:1935/{{application_endpoint}};
22 | {% endfor %}
23 | }
24 |
25 | # transcode definitions
26 | {% for name, transcode_config in transcode_configs.items() %}
27 | application transcode_{{name}} {
28 | live on;
29 | record off;
30 |
31 | # Only allow 127.0.0.1 to publish
32 | allow publish 127.0.0.1;
33 | deny publish all;
34 |
35 | # need to transcode
36 | exec ffmpeg -re -i rtmp://127.0.0.1:1935/$app/$name
37 | -c:v libx264
38 | -s {{transcode_config['pixels']}}
39 | -b:v {{transcode_config['videoBitRate']}}
40 | -bufsize 12M
41 | -r {{transcode_config['videoFrameRate']}}
42 | -x264opts "keyint={{transcode_config['keyFrames']}}:min-keyint={{transcode_config['keyFrames']}}:no-scenecut:nal-hrd=cbr"
43 | {{transcode_config['audioOpts']}}
44 | {% if transcode_config['maxMuxingQueueSize'] %}-max_muxing_queue_size {{transcode_config['maxMuxingQueueSize']}}{% endif %}
45 | -f flv rtmp://127.0.0.1:1935/transcode_output_{{name}}/$name
46 | {% if transcode_config['logFfmpeg'] %} 2>>/tmp/ffmpeg-{{name}}.log {% endif %}
47 | ;
48 | }
49 |
50 | application transcode_output_{{name}} {
51 | live on;
52 | record off;
53 |
54 | # Only allow 127.0.0.1 to publish
55 | allow publish 127.0.0.1;
56 | deny publish all;
57 |
58 | {% for application_endpoint in transcode_config['applicationEndpoints'] %}
59 | push rtmp://127.0.0.1:1935/{{application_endpoint}};
60 | {% endfor %}
61 | }
62 | {% endfor %}
63 |
64 | # application defintions
65 | {% for name, application_config in application_configs.items() %}
66 | application {{ name }} {
67 | live on;
68 | record off;
69 |
70 | # Only allow 127.0.0.1 to publish
71 | allow publish 127.0.0.1;
72 | deny publish all;
73 |
74 | # Push URL
75 | push {{ application_config['pushUrl'] }};
76 | }
77 | {% endfor %}
78 | }
79 | }
--------------------------------------------------------------------------------
/multistreaming-server/rtmp-conf-generator.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import copy
4 | import jinja2
5 | import json
6 | import os
7 | import re
8 | import sys
9 |
10 | #
11 | # Configurable ENV VARS
12 | #
13 |
14 | CONFIG_NGINX_DEBUG = os.getenv('CONFIG_NGINX_DEBUG', False)
15 | CONFIG_FFMPEG_LOG = os.getenv('CONFIG_FFMPEG_LOG', False)
16 | CONFIG_FFMPEG_MAX_MUXING_QUEUE_SIZE = os.getenv(
17 | 'CONFIG_FFMPEG_MAX_MUXING_QUEUE_SIZE', False
18 | )
19 | CONFIG_DISABLE_RECORD = os.getenv('CONFIG_DISABLE_RECORD', False)
20 |
21 | #
22 | # Defaults
23 | #
24 |
25 | RTMP_TRANSCODE_AUDIO_OPTS_COPY = '-c:a copy'
26 | RTMP_TRANSCODE_AUDIO_OPTS_CUSTOM = (
27 | '-c:a libfdk_aac -b:a %%AUDIO_BIT_RATE%% -ar %%AUDIO_SAMPLE_RATE%%'
28 | )
29 |
30 | # https://support.restream.io/en/articles/73108-best-settings
31 | PUSH_URL_RESTREAM = 'rtmp://live.restream.io/live/%%STREAM_KEY%%' # Restream let's you stream to LinkedIn Live
32 | PUSH_URL_YOUTUBE = 'rtmp://a.rtmp.youtube.com/live2/%%STREAM_KEY%%'
33 | PUSH_URL_FACEBOOK = 'rtmp://127.0.0.1:19350/rtmp/%%STREAM_KEY%%'
34 | PUSH_URL_TWITCH = 'rtmp://live-cdg.twitch.tv/app/%%STREAM_KEY%%'
35 | PUSH_URL_INSTAGRAM = 'rtmp://127.0.0.1:19351/rtmp/%%STREAM_KEY%%'
36 | PUSH_URL_PERISCOPE = 'rtmp://%%REGION_CODE%%.pscp.tv:80/x/%%STREAM_KEY%%'
37 | PUSH_URL_MICROSOFT_STREAM = '%%RTMP_URL%% app=live/%%APP_NAME%%'
38 | PUSH_URL_MIXCLOUD = 'rtmp://rtmp.mixcloud.com/broadcast/%%STREAM_KEY%%'
39 | PUSH_URL_DLIVE = 'rtmp://stream.dlive.tv/live/%%STREAM_KEY%%'
40 |
41 |
42 | DEFAULT_TRANSCODE_CONFIG = {
43 | 'pixels': '1280x720',
44 | 'videoBitRate': '4500k',
45 | 'videoFrameRate': 60,
46 | 'keyFrames': 60,
47 | 'audioOpts': '-c:a copy',
48 | 'logFfmpeg': True if CONFIG_FFMPEG_LOG else False,
49 | 'maxMuxingQueueSize': int(CONFIG_FFMPEG_MAX_MUXING_QUEUE_SIZE)
50 | if CONFIG_FFMPEG_MAX_MUXING_QUEUE_SIZE
51 | else None,
52 | }
53 |
54 | DEFAULT_AUDIO_OPTS = {
55 | 'audioBitRate': '160k',
56 | 'audioSampleRate': '48000',
57 | }
58 |
59 |
60 | def generatePlatormPushURL(block_config):
61 | if 'platform' not in block_config:
62 | print('ERROR - Application block is missing platform element.', file=sys.stderr)
63 | exit(1)
64 | push_url = 'push-it-real-good'
65 |
66 | if block_config['platform'] == 'restream':
67 | push_url = PUSH_URL_RESTREAM.replace('%%STREAM_KEY%%', block_config['streamKey'])
68 | elif block_config['platform'] == 'youtube':
69 | push_url = PUSH_URL_YOUTUBE.replace('%%STREAM_KEY%%', block_config['streamKey'])
70 | elif block_config['platform'] == 'facebook':
71 | # must push through stunnel. Push through Facebook stunnel port.
72 | push_url = PUSH_URL_FACEBOOK.replace(
73 | '%%STREAM_KEY%%', block_config['streamKey']
74 | )
75 | elif block_config['platform'] == 'twitch':
76 | push_url = PUSH_URL_TWITCH.replace('%%STREAM_KEY%%', block_config['streamKey'])
77 | elif block_config['platform'] == 'instagram':
78 | # must push through stunnel. Push through Instagram stunnel port.
79 | push_url = PUSH_URL_INSTAGRAM.replace(
80 | '%%STREAM_KEY%%', block_config['streamKey']
81 | )
82 | elif block_config['platform'] == 'periscope':
83 | region_code = (
84 | block_config['regionCode'] if 'regionCode' in block_config else 'ca'
85 | )
86 | push_url = PUSH_URL_PERISCOPE.replace(
87 | '%%STREAM_KEY%%', block_config['streamKey']
88 | ).replace('%%REGION_CODE%%', region_code)
89 | elif block_config['platform'] == 'custom':
90 | push_url = block_config['customRTMPURL']
91 | elif block_config['platform'] == 'microsoft-stream':
92 | ms_source_url = block_config['fullRTMPURL']
93 | ms_rtmp_url = re.search(r'^(.*)/live/', ms_source_url).group(1)
94 | ms_app_name = re.search(r'/live/(.*)$', ms_source_url).group(1)
95 | push_url = PUSH_URL_MICROSOFT_STREAM.replace(
96 | '%%RTMP_URL%%', ms_rtmp_url
97 | ).replace('%%APP_NAME%%', ms_app_name)
98 | elif block_config['platform'] == 'mixcloud':
99 | push_url = PUSH_URL_MIXCLOUD.replace(
100 | '%%STREAM_KEY%%', block_config['streamKey']
101 | )
102 | elif block_config['platform'] == 'dlive':
103 | push_url = PUSH_URL_DLIVE.replace('%%STREAM_KEY%%', block_config['streamKey'])
104 | else:
105 | print(
106 | 'ERROR - an unsupported platform type was provided in destination configation',
107 | file=sys.stderr,
108 | )
109 | exit(1)
110 | return push_url
111 |
112 |
113 | def generateTranscodeConfig(transcode_config_name, block_config, config):
114 | block_config_name = block_config['name']
115 | default_transcode_config = DEFAULT_TRANSCODE_CONFIG.copy()
116 | transcode_config_block = config.get('transcodeProfiles', {}).get(
117 | transcode_config_name, block_config.get('transcode')
118 | )
119 | if not transcode_config_block:
120 | print(
121 | f'ERROR - unable to resolve transcode profile for {transcode_config_name} in block {block_config_name}',
122 | file=sys.stderr,
123 | )
124 | exit(1)
125 | transcode_config = {
126 | key: copy.deepcopy(transcode_config_block.get(key, DEFAULT_TRANSCODE_CONFIG[key]))
127 | for key in default_transcode_config.keys()
128 | }
129 | transcode_config['applicationEndpoints'] = set()
130 |
131 | if 'videoKeyFrameSecs' in transcode_config_block:
132 | transcode_config['keyFrames'] = 30 * transcode_config_block['videoKeyFrameSecs']
133 |
134 | if ('audioBitRate' in transcode_config_block) or (
135 | 'audioSampleRate' in transcode_config_block
136 | ):
137 | transcode_config['audioOpts'] = RTMP_TRANSCODE_AUDIO_OPTS_CUSTOM.replace(
138 | '%%AUDIO_BIT_RATE%%',
139 | str(transcode_config_block.get(
140 | 'audioBitRate', DEFAULT_AUDIO_OPTS['audioBitRate']
141 | )),
142 | ).replace(
143 | '%%AUDIO_SAMPLE_RATE%%',
144 | str(transcode_config_block.get(
145 | 'audioSampleRate', DEFAULT_AUDIO_OPTS['audioSampleRate']
146 | )),
147 | )
148 | return transcode_config
149 |
150 |
151 | def loadJsonConfig(path):
152 | try:
153 | with open(path, 'r') as f:
154 | config = json.load(f)
155 | except json.decoder.JSONDecodeError as err:
156 | print(
157 | "ERROR decoding JSON config file '{0}': {1}".format(path, err),
158 | file=sys.stderr,
159 | )
160 | exit(1)
161 | except:
162 | print("ERROR loading JSON config file '{0}'".format(path), file=sys.stderr)
163 | exit(1)
164 |
165 | return config
166 |
167 |
168 | def generateConfig(config_file, nginx_config_template):
169 | config = loadJsonConfig(config_file)
170 | # unfortunate hack to make code compatible with previous spelling error
171 | config_list_key = (
172 | 'rebroacastList' if 'rebroacastList' in config else 'rebroadcastList'
173 | )
174 |
175 | record_mode = 'off' if CONFIG_DISABLE_RECORD else 'all'
176 | nginx_error_log = True if CONFIG_NGINX_DEBUG else False
177 |
178 | endpoint_name = config['endpoint'] if 'endpoint' in config else 'live'
179 |
180 | application_configs = {}
181 | transcode_configs = {}
182 | push_only_applications = set()
183 |
184 | for block_config in config[config_list_key]:
185 | if block_config.get('disabled', False):
186 | continue
187 |
188 | block_config_name = block_config['name']
189 | application_configs[block_config_name] = {
190 | 'pushUrl': generatePlatormPushURL(block_config)
191 | }
192 |
193 | if ('transcodeProfile' in block_config) or 'transcode' in block_config:
194 | transcode_config_name = block_config.get(
195 | 'transcodeProfile', f'inline_{block_config_name}'
196 | )
197 |
198 | if transcode_config_name not in transcode_configs:
199 | transcode_configs[transcode_config_name] = generateTranscodeConfig(
200 | transcode_config_name, block_config, config
201 | )
202 | transcode_configs[transcode_config_name]['applicationEndpoints'].add(
203 | block_config_name
204 | )
205 |
206 | else:
207 | push_only_applications.add(block_config_name)
208 |
209 | with open(nginx_config_template) as fh:
210 | template = jinja2.Template(fh.read())
211 |
212 | return template.render(
213 | nginx_error_log=nginx_error_log,
214 | endpoint_name=endpoint_name,
215 | record_mode=record_mode,
216 | transcode_configs=transcode_configs,
217 | push_only_applications=push_only_applications,
218 | application_configs=application_configs,
219 | )
220 |
221 |
222 | if __name__ == '__main__':
223 | if len(sys.argv) != 3:
224 | print(
225 | 'Must pass two arguments of the JSON configuration file path and the nginx config.',
226 | file=sys.stderr,
227 | )
228 | sys.exit(1)
229 | rtmp_conf = generateConfig(sys.argv[1], sys.argv[2])
230 | print(rtmp_conf)
231 |
--------------------------------------------------------------------------------
/multistreaming-server/stunnel-conf/etc-default-stunnel:
--------------------------------------------------------------------------------
1 | # /etc/default/stunnel
2 | # Julien LEMOINE
3 | # September 2003
4 |
5 | # Change to one to enable stunnel automatic startup
6 | ENABLED=1
7 | FILES="/etc/stunnel/*.conf"
8 | OPTIONS=""
9 |
10 | # Change to one to enable ppp restart scripts
11 | PPP_RESTART=0
12 |
13 | # Change to enable the setting of limits on the stunnel instances
14 | # For example, to set a large limit on file descriptors (to enable
15 | # more simultaneous client connections), set RLIMITS="-n 4096"
16 | # More than one resource limit may be modified at the same time,
17 | # e.g. RLIMITS="-n 4096 -d unlimited"
18 | RLIMITS=""
19 |
--------------------------------------------------------------------------------
/multistreaming-server/stunnel-conf/etc-stunnel-conf.d-fb.conf:
--------------------------------------------------------------------------------
1 | [fb-live]
2 | client = yes
3 | accept = 127.0.0.1:19350
4 | connect = live-api-s.facebook.com:443
5 | verifyChain = no
6 |
--------------------------------------------------------------------------------
/multistreaming-server/stunnel-conf/etc-stunnel-conf.d-ig.conf:
--------------------------------------------------------------------------------
1 | [ig-live]
2 | client = yes
3 | accept = 127.0.0.1:19351
4 | connect = live-upload.instagram.com:443
5 | verifyChain = no
6 |
--------------------------------------------------------------------------------
/multistreaming-server/stunnel-conf/etc-stunnel-stunnel.conf:
--------------------------------------------------------------------------------
1 | foreground = yes
2 | setuid = stunnel
3 | setgid = stunnel
4 | pid= /tmp/stunnel.pid
5 | output = /tmp/stunnel.log
6 | include = /etc/stunnel/conf.d
--------------------------------------------------------------------------------
/multistreaming-server/supervisord.conf:
--------------------------------------------------------------------------------
1 | [supervisord]
2 | nodaemon=true
3 | user=root
4 |
5 | [supervisorctl]
6 |
7 | [program:stunnel]
8 | command=/usr/bin/stunnel
9 | stdout_logfile=/dev/stdout
10 | stdout_logfile_maxbytes=0
11 | stderr_logfile=/dev/stderr
12 | stderr_logfile_maxbytes=0
13 | autorestart=true
14 |
15 |
16 | [program:stunnel-logs]
17 | command=tail -f /tmp/stunnel.log
18 | stdout_logfile=/dev/stdout
19 | stdout_logfile_maxbytes=0
20 | stderr_logfile=/dev/stderr
21 | stderr_logfile_maxbytes=0
22 | autorestart=true
23 |
24 |
25 | [program:nginx]
26 | command=sh /launch-nginx-server.sh
27 | stdout_logfile=/dev/stdout
28 | stdout_logfile_maxbytes=0
29 | stderr_logfile=/dev/stderr
30 | stderr_logfile_maxbytes=0
31 | autorestart=true
32 |
--------------------------------------------------------------------------------