diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0184b45 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: goreleaser + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - + name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.16 + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + distribution: goreleaser + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adfefb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode +dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d7644c0 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,30 @@ +linters: + enable-all: true + disable: + - gochecknoinits + - gochecknoglobals + - goconst + - wsl + - lll + - goerr113 + - goprintffuncname + - gomnd + - nlreturn + - gosec + - exhaustivestruct + - interfacer + - maligned + - scopelint + - golint + +output: + format: tab + +run: + timeout: 10s + +linters-settings: + nestif: + min-complexity: 10 + gci: + local-prefixes: github.com/shaunschembri/restreamer \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..6631fb8 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,26 @@ +project_name: restreamer +before: + hooks: + - go mod tidy +builds: +- dir: cmd/restreamer + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm + - arm64 + - mips + - mipsle + ignore: + - goos: windows + goarch: arm + ldflags: + - -s -w -X main.version={{.Version}} +archives: + - replacements: + darwin: macos +checksum: + name_template: 'checksums.txt' \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34007db --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Copyright 2021 Shaun Schembri + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1fe32d9 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +build: + @ go build -o restreamer -ldflags="-s -w" cmd/restreamer/main.go + +lint: + @ ~/go/bin/golangci-lint run --fix --deadline=10s + +snapshot: + @ goreleaser --snapshot --skip-publish --rm-dist + +tag: + @ git tag -f -a ${TAG} -m ${TAG} + @ git push -f origin ${TAG} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..26ffc5c --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# Restreamer + +Simple golang application that downloads the media segments from an [HTTP Live Streaming (HLS)](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) and streams the segments over an HTTP connection or stores them to disk as a single file. It is written in Go which makes it extremely portable and uses little system resources which makes it ideal to be used on embedded devices where resources are limited (ex TV set top boxes). + +This application enables media players with limited or no support for HLS to play these streams as many media players are able to play a stream sourced through a single HTTP connection. Alternatively the stream can be saved to a storage device and viewed as any other media file, possibly even while downloading as a time-shifted programme. + +## Features +- Support non-encrypted and AES128 encrypted streams +- Automatically detects if the M3U8 contains a master or media playlist +- Automatic selection of a stream variant from the master playlist depending on the available bandwidth + +## Quick Start Guide +- Download `restreamer` binary for you target system. Pre-build binaries are available [here](https://github.com/shaunschembri/restreamer/releases) alternatively build from source following the [Building restreamer](#building-restreamer) section. +- Edit [restreamer.yaml](configs/restreamer.yaml) and add the M3U8 URLs for the streams you like to use, giving each URL a unique stream id. +- Save the config on the target system. By default the config file is expected to be in `$HOME\.restreamer` but this can be stored in any accessible path and passed using the `--config` argument. + +### Using restreamer over HTTP +- Execute `restreamer server` +- Initiate playback on your media player of choice by streaming from `http://ip-address:port/stream-id` Example `http://localhost:1230/nasatv1` + +Available options for `server` sub-command are + +``` + -h, --help help for server + -a, --http-address string http server bind address (default "127.0.0.1") + -p, --http-port int http server listening port (default 1230) +``` + +### Using restreamer to download to local storage +Execute `restreamer download -s nasatv1 -t 1h` which would stream the channel for 1 hour and store all segments as a single file in the path provided. + +WARNING: While there is no technical limitation to use `restreamer` over the public internet, this is strongly not recommended due to the lack of secure transport (HTTPS) and authentication in the implementation. However, it is very possible to re-stream over a LAN where the lack of security is not an issue. + +Available options for download sub-command are + +``` + -d, --download-path string path to store downloaded media (default ".") + -t, --duration duration stream duration (default 12h0m0s) + -f, --filename string filename of downloaded media + -h, --help help for download + -s, --stream-id string stream id +``` + +### Global flags +Both of the sub-commands can also control some parameters of `restreamer` library. These commands are + +``` + -c, --config string config file + -m, --max-bandwidth float max bandwidth in mb/sec (default 10) + -b, --read-buffer float read buffer in mb (default 1) +``` + +## Building restreamer +- Install go `v1.16` or later. You can obtain the binaries for you operating from [here](https://golang.org/dl/) +- Clone this repo with `git clone https://github.com/shaunschembri/restreamer` +- Execute `go build -o restreamer cmd/restreamer/main.go` to create a binary that can be executed on your local machine. +- To build for a different system set the `GOOS`, `GOARCH` and other target specific environmental variables before executing the above command. The complete list of valid combinations can be found [here](https://golang.org/doc/install/source#environment). Example to build for an ARM linux set-top box you need to execute `GOOS=linux GOARCH=arm GOARM=6 go build...` + +## Using the library in your code + +All the heavy lifting happens in the [restreamer](https://github.com/shaunschembri/restreamer/tree/main/pkg/restream) package and the stream is returned to the calling code through an `io.Writer` so it can be easily consumed by other applications. While the [restreamer app](https://github.com/shaunschembri/restreamer/tree/main/internal/restreamer) is a complete and simple enough example an ever simpler example to download a stream to disk is shown below + +```go +package main + +import ( + "context" + "log" + "os" + + "github.com/shaunschembri/restreamer/pkg/restream" +) + +func main() { + file, err := os.Create("nasatv1.ts") + if err != nil { + log.Fatalln("Error: cannot open file nasatv1.ts for writing") + } + + restreamer := restream.Restream{ + Writer: file, + } + + if err := restreamer.Start(context.Background(), "https://ntv1.akamaized.net/hls/live/2014075/NASA-NTV1-HLS/master.m3u8"); err != nil { + log.Fatalln(err) + } +} +``` + +## Future work +- Support remuxing of the output stream, making it possible to add subtitles and audio streams provided through separate segments. +- Support other [Adaptive Bitrate Streaming](https://en.wikipedia.org/wiki/Adaptive_bitrate_streaming) systems like [MPEG-DASH](https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP). The code has been on propose developed to be generic enough to support other systems that break down the video stream in multiple segments. +- Support `SAMPLE-AES` encryption, provided a good example not tied with a proprietary DRM system is available. +- Cover all code with a comprehensive test suite. + +## License +Licensed under the [3-Clause BSD License](LICENSE.txt) \ No newline at end of file diff --git a/cmd/restreamer/main.go b/cmd/restreamer/main.go new file mode 100644 index 0000000..15cca35 --- /dev/null +++ b/cmd/restreamer/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "log" + + "github.com/shaunschembri/restreamer/internal/restreamer" +) + +var version string + +func main() { + log.Printf("Starting restreamer %s", version) + restreamer.Main() +} diff --git a/configs/restreamer.yaml b/configs/restreamer.yaml new file mode 100644 index 0000000..6390bc7 --- /dev/null +++ b/configs/restreamer.yaml @@ -0,0 +1,12 @@ +max-bandwidth: 10 +read-buffer: 1 + +server: + address: 127.0.0.1 + port: 1230 + +download: + path: . + +streams: + nasatv1: https://ntv1.akamaized.net/hls/live/2014075/NASA-NTV1-HLS/master.m3u8 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4f3cefa --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/shaunschembri/restreamer + +go 1.16 + +require ( + github.com/grafov/m3u8 v0.11.1 + github.com/spf13/cobra v1.1.3 + github.com/spf13/viper v1.7.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8b3778a --- /dev/null +++ b/go.sum @@ -0,0 +1,314 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA= +github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M= +github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= +github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/internal/restreamer/command.go b/internal/restreamer/command.go new file mode 100644 index 0000000..a40f281 --- /dev/null +++ b/internal/restreamer/command.go @@ -0,0 +1,61 @@ +package restreamer + +import ( + "log" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +var rootCmd = &cobra.Command{ + Use: "restreamer", +} + +func Main() { + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + viper.SetConfigName("restreamer") + viper.SetConfigType("yaml") + viper.AddConfigPath(filepath.Join(os.Getenv("HOME"), ".restreamer")) + } + + if err := viper.ReadInConfig(); err != nil { + log.Fatal("restreamer.yaml not found...exiting") + } +} + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file") + rootCmd.PersistentFlags().Float64P("max-bandwidth", "m", 10, "max bandwidth in mb/sec") + rootCmd.PersistentFlags().Float64P("read-buffer", "b", 1, "read buffer in MB") + + bindFlagToConfig(rootCmd, "max-bandwidth", "max-bandwidth") + bindFlagToConfig(rootCmd, "read-buffer", "read-buffer") +} + +func bindFlagToConfig(cmd *cobra.Command, flag, configPath string) { + pflag := cmd.Flags().Lookup(flag) + if pflag == nil { + pflag = cmd.PersistentFlags().Lookup(flag) + } + if pflag == nil { + log.Fatalf("flag %s not found", flag) + } + + if err := viper.BindPFlag(configPath, pflag); err != nil { + log.Fatal(err) + } +} diff --git a/internal/restreamer/download.go b/internal/restreamer/download.go new file mode 100644 index 0000000..4cb1e91 --- /dev/null +++ b/internal/restreamer/download.go @@ -0,0 +1,57 @@ +package restreamer + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var downloadCmd = &cobra.Command{ + Use: "download", + Short: "Download and save stream", + Run: func(cmd *cobra.Command, args []string) { + streamID, _ := cmd.Flags().GetString("stream-id") + fileName, _ := cmd.Flags().GetString("filename") + if fileName == "" { + fileName = filepath.Join(viper.GetString("download.path"), fmt.Sprintf("%s_%d.ts", streamID, time.Now().Unix())) + } + + file, err := os.Create(fileName) + if err != nil { + log.Printf("Error: cannot open file %s", fileName) + return + } + + duration, _ := cmd.Flags().GetDuration("duration") + segmentsContext, cancel := context.WithTimeout(context.Background(), duration) + defer cancel() + + log.Printf("Starting to download stream with id %s for %v", streamID, duration) + if err := start(segmentsContext, file, streamID); err != nil { + log.Printf("Error: %v", err) + } + log.Printf("Download stream with id %s stopped", streamID) + + file.Close() + }, +} + +func init() { + downloadCmd.Flags().StringP("download-path", "d", ".", "path to store downloaded media") + downloadCmd.Flags().StringP("filename", "f", "", "filename of downloaded media") + downloadCmd.Flags().StringP("stream-id", "s", "", "stream id") + downloadCmd.Flags().DurationP("duration", "t", time.Hour*12, "stream duration") + + bindFlagToConfig(downloadCmd, "download-path", "download.path") + if err := downloadCmd.MarkFlagRequired("stream-id"); err != nil { + log.Fatalln(err) + } + + rootCmd.AddCommand(downloadCmd) +} diff --git a/internal/restreamer/restreamer.go b/internal/restreamer/restreamer.go new file mode 100644 index 0000000..5084b22 --- /dev/null +++ b/internal/restreamer/restreamer.go @@ -0,0 +1,33 @@ +package restreamer + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/viper" + + "github.com/shaunschembri/restreamer/pkg/restream" +) + +const mbMultiplier = 1048576 + +func start(ctx context.Context, writer io.Writer, streamID string) error { + streamer := restream.Restream{ + Writer: writer, + MaxBandwidth: uint32(viper.GetFloat64("max-bandwidth") * mbMultiplier), + ReadBufferSize: int(viper.GetFloat64("read-buffer") * mbMultiplier), + } + + streams := viper.GetStringMapString("streams") + streamURL, ok := streams[streamID] + if !ok { + return fmt.Errorf("url for stream with id %s not found in config", streamID) + } + + if err := streamer.Start(ctx, streamURL); err != nil { + return fmt.Errorf("restreamer error %w", err) + } + + return nil +} diff --git a/internal/restreamer/server.go b/internal/restreamer/server.go new file mode 100644 index 0000000..c59ae3b --- /dev/null +++ b/internal/restreamer/server.go @@ -0,0 +1,47 @@ +package restreamer + +import ( + "fmt" + "log" + "net/http" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Start HTTP server", + Run: func(cmd *cobra.Command, args []string) { + addr := fmt.Sprintf("%s:%d", viper.GetString("server.address"), viper.GetInt("server.port")) + + http.HandleFunc("/", httpStream) + + log.Printf("Starting HTTP server on %s", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalln(err) + } + }, +} + +func init() { + serverCmd.Flags().IntP("http-port", "p", 1230, "http server listening port") + serverCmd.Flags().StringP("http-address", "a", "127.0.0.1", "http server bind address") + + bindFlagToConfig(serverCmd, "http-port", "server.port") + bindFlagToConfig(serverCmd, "http-address", "server.address") + + rootCmd.AddCommand(serverCmd) +} + +func httpStream(writer http.ResponseWriter, request *http.Request) { + streamID := request.URL.Path[1:] + + log.Printf("Starting to restream stream with id %s", streamID) + if err := start(request.Context(), writer, streamID); err != nil { + log.Println(err.Error()) + return + } + + log.Printf("Restream of stream with id %s stopped", streamID) +} diff --git a/pkg/restream/decrypt.go b/pkg/restream/decrypt.go new file mode 100644 index 0000000..9e35d46 --- /dev/null +++ b/pkg/restream/decrypt.go @@ -0,0 +1,85 @@ +package restream + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/hex" + "fmt" + "io" + "strings" +) + +type decrypter interface { + decrypt([]byte) ([]byte, error) + init(ctx context.Context) error + info() string +} + +type aes128 struct { + iv string + keyURL string + bufferSize int + request request + mode cipher.BlockMode +} + +func (a aes128) info() string { + return "AES128" +} + +func (a *aes128) init(ctx context.Context) error { + keyFileResponse, err := a.request.do(ctx, a.keyURL) + if err != nil { + return err + } + defer keyFileResponse.Body.Close() + + key, err := io.ReadAll(keyFileResponse.Body) + if err != nil { + return fmt.Errorf("cannot read key from %s: %w", a.keyURL, err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return fmt.Errorf("cannot create new AES cipher: %w", err) + } + + iv, err := hex.DecodeString(fmt.Sprintf("%032s", strings.ReplaceAll(a.iv, "0x", ""))) + if err != nil { + return fmt.Errorf("cannot decode IV %s: %w", iv, err) + } + + a.mode = cipher.NewCBCDecrypter(block, iv) + + return nil +} + +func (a *aes128) decrypt(payload []byte) ([]byte, error) { + payloadSize := len(payload) + if payloadSize%aes.BlockSize != 0 { + return nil, fmt.Errorf("payload size is not a multiple of %d", aes.BlockSize) + } + + // Decrypt payload + a.mode.CryptBlocks(payload, payload) + + // If last byte in payload is bigger then the block size (16) or the last byte + // is not a multiple of 4, then there is no padding and the entire decrypted payload + // is returned. + lastByte := int(payload[payloadSize-1]) + if lastByte > aes.BlockSize || lastByte%4 != 0 { + return payload, nil + } + + // As per PKCS#7 specification the last byte value will contain the number of bytes + // that were added as padding and all these extra bytes will have the same value as the + // last byte. + for _, paddingByte := range payload[payloadSize-lastByte:] { + if paddingByte != byte(lastByte) { + return payload, nil + } + } + + return payload[:payloadSize-lastByte], nil +} diff --git a/pkg/restream/master.go b/pkg/restream/master.go new file mode 100644 index 0000000..338284f --- /dev/null +++ b/pkg/restream/master.go @@ -0,0 +1,66 @@ +package restream + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/grafov/m3u8" +) + +type hlsMaster struct { + hlsMedia + resolution string + maxBandwidth uint32 + variantBandwidth uint32 + playlist *m3u8.MasterPlaylist + referenceURL *url.URL +} + +func (h hlsMaster) Info() string { + infoStr := fmt.Sprintf("Master | Bandwidth: %3.1fMb/s", float32(h.variantBandwidth)/mbDivider) + if h.resolution != "" { + infoStr += fmt.Sprintf(" | Resolution: %s", h.resolution) + } + + return infoStr +} + +func (h *hlsMaster) Get(ctx context.Context, bandwidth uint32) ([]segment, time.Duration, error) { + if err := h.selectVariant(bandwidth); err != nil { + return nil, 0, err + } + + return h.hlsMedia.Get(ctx, bandwidth) +} + +func (h *hlsMaster) selectVariant(streamSpeed uint32) error { + var targetVariant *m3u8.Variant + minDiff := streamSpeed + + for _, variant := range h.playlist.Variants { + if variant.Bandwidth > h.maxBandwidth || variant.Bandwidth > streamSpeed { + continue + } + + diff := streamSpeed - variant.Bandwidth + if diff >= minDiff && targetVariant != nil { + continue + } + + minDiff = diff + targetVariant = variant + } + + parsedURI, err := h.request.resolveReference(targetVariant.URI, h.referenceURL) + if err != nil { + return err + } + + h.mediaPlaylistURL = parsedURI.String() + h.resolution = targetVariant.Resolution + h.variantBandwidth = targetVariant.Bandwidth + + return nil +} diff --git a/pkg/restream/media.go b/pkg/restream/media.go new file mode 100644 index 0000000..f8cae5c --- /dev/null +++ b/pkg/restream/media.go @@ -0,0 +1,97 @@ +package restream + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/grafov/m3u8" +) + +type hlsPlaylist struct { + playlist m3u8.Playlist + listType m3u8.ListType + requestURL *url.URL +} + +type hlsMedia struct { + request request + mediaPlaylistURL string + lastMediaSeq uint64 +} + +func (h hlsMedia) getPlaylist(ctx context.Context) (*hlsPlaylist, error) { + response, err := h.request.do(ctx, h.mediaPlaylistURL) + if err != nil { + return nil, err + } + defer response.Body.Close() + + playlist, listType, err := m3u8.DecodeFrom(response.Body, true) + if err != nil { + return nil, fmt.Errorf("failed to decode playlist: %w", err) + } + + return &hlsPlaylist{ + playlist: playlist, + listType: listType, + requestURL: response.Request.URL, + }, nil +} + +func (h hlsMedia) Info() string { + return "Media" +} + +func (h *hlsMedia) Get(ctx context.Context, bandwidth uint32) ([]segment, time.Duration, error) { + playlist, err := h.getPlaylist(ctx) + if err != nil { + return nil, 0, err + } + + mediaPlaylist, ok := playlist.playlist.(*m3u8.MediaPlaylist) + if !ok { + return nil, 0, fmt.Errorf("cannot assert to a media playlist from url %s", h.mediaPlaylistURL) + } + + newSegmentsFound := false + mediaSeq := mediaPlaylist.SeqNo + segments := make([]segment, 0) + for _, mediaSegment := range mediaPlaylist.Segments { + if mediaSegment != nil { + if mediaSeq > h.lastMediaSeq { + newSegmentsFound = true + h.lastMediaSeq = mediaSeq + + url, err := h.request.resolveReference(mediaSegment.URI, playlist.requestURL) + if err != nil { + return nil, 0, fmt.Errorf("cannot resolve reference URL: %w", err) + } + + segment := segment{ + url: url.String(), + keyMethod: "NONE", + duration: mediaSegment.Duration, + } + if mediaSegment.Key != nil { + segment.keyMethod = mediaSegment.Key.Method + segment.keyURL = mediaSegment.Key.URI + segment.iv = mediaSegment.Key.IV + } + + segments = append(segments, segment) + } + + mediaSeq++ + } + } + + // Reload playlist according to https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-6.3.4 + reloadPlaylistAfter := time.Duration(mediaPlaylist.TargetDuration * float64(time.Second)) + if !newSegmentsFound { + reloadPlaylistAfter /= 2 + } + + return segments, reloadPlaylistAfter, nil +} diff --git a/pkg/restream/new.go b/pkg/restream/new.go new file mode 100644 index 0000000..50358ab --- /dev/null +++ b/pkg/restream/new.go @@ -0,0 +1,66 @@ +package restream + +import ( + "context" + "io" + "time" +) + +const ( + defaultUserAgent = "restreamer" + defaultBandwidth = 10485760 + defaultReadBufferSize = 1048576 + mbDivider = 1048576 +) + +type Restream struct { + UserAgent string + MaxBandwidth uint32 + Writer io.Writer + SegmentProvider SegmentProvider + ReadBufferSize int + streamedBytes int64 + currentBandwidth uint32 + segments chan segment + errors chan error + decrypter decrypter +} + +type segment struct { + url string + keyMethod string + keyURL string + iv string + duration float64 +} + +type SegmentProvider interface { + Get(ctx context.Context, bandwidth uint32) ([]segment, time.Duration, error) + Info() string +} + +func (r *Restream) init(ctx context.Context, playlistURL string) error { + r.segments = make(chan segment, 1024) + r.errors = make(chan error, 1024) + + if r.MaxBandwidth == 0 { + r.MaxBandwidth = defaultBandwidth + } + r.currentBandwidth = r.MaxBandwidth + + if r.ReadBufferSize == 0 { + r.ReadBufferSize = defaultReadBufferSize + } + + if r.UserAgent == "" { + r.UserAgent = defaultUserAgent + } + + if r.SegmentProvider == nil { + if err := r.getSegmentProvider(ctx, playlistURL); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/restream/request.go b/pkg/restream/request.go new file mode 100644 index 0000000..a6d3545 --- /dev/null +++ b/pkg/restream/request.go @@ -0,0 +1,70 @@ +package restream + +import ( + "compress/gzip" + "context" + "fmt" + "log" + "net/http" + "net/url" + "time" +) + +type request struct { + client *http.Client + userAgent string +} + +func (r request) do(ctx context.Context, requestURL string) (*http.Response, error) { + response, err := r.attemptRequest(ctx, requestURL) + if err != nil || response == nil { + log.Printf("request to %s failed with error %v. Will retry in 1 second", requestURL, err) + time.Sleep(time.Second) + return r.do(ctx, requestURL) + } + + if response.StatusCode < http.StatusBadRequest { + return response, nil + } + + if response.StatusCode != http.StatusNotFound { + log.Printf("request to %s failed with status code %d. Will retry in 1 second", requestURL, response.StatusCode) + time.Sleep(time.Second) + return r.do(ctx, requestURL) + } + + return nil, fmt.Errorf("request to %s failed with status code %d", requestURL, response.StatusCode) +} + +func (r request) attemptRequest(context context.Context, requestURL string) (*http.Response, error) { + request, err := http.NewRequestWithContext(context, "GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + request.Header.Add("User-Agent", r.userAgent) + request.Header.Add("Accept-Encoding", "gzip") + + response, err := r.client.Do(request) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + + if response.Header.Get("Content-Encoding") == "gzip" { + reader, err := gzip.NewReader(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + response.Body = reader + } + + return response, nil +} + +func (r request) resolveReference(uri string, referenceURL *url.URL) (*url.URL, error) { + parsedURI, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("cannot parse segment uri %s: %w", uri, err) + } + + return referenceURL.ResolveReference(parsedURI), nil +} diff --git a/pkg/restream/restream.go b/pkg/restream/restream.go new file mode 100644 index 0000000..835eefb --- /dev/null +++ b/pkg/restream/restream.go @@ -0,0 +1,93 @@ +package restream + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "time" + + "github.com/grafov/m3u8" +) + +func (r Restream) Start(ctx context.Context, playlistURL string) error { + if err := r.init(ctx, playlistURL); err != nil { + return err + } + + segmentsContext, cancel := context.WithCancel(ctx) + defer cancel() + go r.getSegments(segmentsContext) + + for { + segments, sleepTime, err := r.SegmentProvider.Get(ctx, r.currentBandwidth) + if err != nil { + return fmt.Errorf("failed to get new segments: %w", err) + } + + for _, segment := range segments { + r.segments <- segment + } + + select { + case <-ctx.Done(): + r.displayStats() + return nil + case err := <-r.errors: + return err + case <-time.After(sleepTime): + r.displayStats() + } + } +} + +func (r Restream) displayStats() { + statsString := fmt.Sprintf("Streamed: %5.1fMB | Calculated Bandwidth: %4.1fMb/s", + float64(r.streamedBytes)/mbDivider, float64(r.currentBandwidth)/mbDivider) + + if r.decrypter != nil { + statsString += fmt.Sprintf(" | Decrypter %s", r.decrypter.info()) + } + + log.Printf("%s | Playlist Type %s", statsString, r.SegmentProvider.Info()) +} + +func (r *Restream) getSegmentProvider(ctx context.Context, playlistURL string) error { + hlsMedia := hlsMedia{ + request: request{ + userAgent: r.UserAgent, + client: &http.Client{}, + }, + mediaPlaylistURL: playlistURL, + } + + playlist, err := hlsMedia.getPlaylist(ctx) + if err != nil { + return err + } + + if playlist.listType == m3u8.MEDIA { + r.SegmentProvider = &hlsMedia + return nil + } + + if playlist.listType == m3u8.MASTER { + masterPlaylist, ok := playlist.playlist.(*m3u8.MasterPlaylist) + if !ok { + return fmt.Errorf("cannot assert to a master playlist") + } + + hlsMaster := hlsMaster{ + playlist: masterPlaylist, + referenceURL: playlist.requestURL, + hlsMedia: hlsMedia, + maxBandwidth: r.MaxBandwidth, + } + + r.SegmentProvider = &hlsMaster + return nil + } + + return errors.New("invalid playlist list type") +} diff --git a/pkg/restream/segment.go b/pkg/restream/segment.go new file mode 100644 index 0000000..5cfdf81 --- /dev/null +++ b/pkg/restream/segment.go @@ -0,0 +1,142 @@ +package restream + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +const decrypterBuffer = 32768 + +func (r *Restream) getSegments(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case segment := <-r.segments: + switch segment.keyMethod { + case "AES-128": + r.decrypter = &aes128{ + iv: segment.iv, + keyURL: segment.keyURL, + bufferSize: decrypterBuffer, + request: request{ + userAgent: r.UserAgent, + client: &http.Client{}, + }, + } + + if err := r.decrypter.init(ctx); err != nil { + r.errors <- fmt.Errorf("error initiating decrypter %s: %w", r.decrypter.info(), err) + return + } + + case "NONE": + r.decrypter = nil + default: + r.decrypter = nil + r.errors <- fmt.Errorf("key method %s is not supported", segment.keyMethod) + return + } + + if err := r.writeSegment(ctx, segment.url); err != nil { + r.errors <- err + return + } + } + } +} + +func (r *Restream) writeSegment(ctx context.Context, url string) error { + request := request{ + userAgent: r.UserAgent, + client: &http.Client{}, + } + + response, err := request.do(ctx, url) + if err != nil { + return err + } + defer response.Body.Close() + + if r.Writer == nil { + return fmt.Errorf("stopping streaming as writer is nil") + } + + reader := bufio.NewReaderSize(response.Body, r.ReadBufferSize) + startTime := time.Now() + if _, err := reader.Peek(r.ReadBufferSize); err != nil { + if !errors.Is(err, io.EOF) { + return fmt.Errorf("error filling buffer: %w", err) + } + } + r.currentBandwidth = uint32(float64(reader.Buffered()*8) / time.Since(startTime).Seconds()) + + segmentSize := 0 + writer := NewStreamWriter(ctx, r.Writer) + buffer := make([]byte, decrypterBuffer) + + for { + bytesRead, readErr := io.ReadFull(reader, buffer) + if errors.Is(readErr, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("error reading stream: %w", err) + } + + decryptedPayload, err := r.decrypt(buffer, bytesRead) + if err != nil { + return fmt.Errorf("cannot decrypt: %w", err) + } + + bytesWritten, err := writer.Write(decryptedPayload) + if err != nil { + return fmt.Errorf("error writing output: %w", err) + } + + r.streamedBytes += int64(bytesWritten) + segmentSize += bytesWritten + } + + return nil +} + +func (r *Restream) decrypt(payload []byte, bytesRead int) ([]byte, error) { + if r.decrypter != nil { + decryptedPayload, err := r.decrypter.decrypt(payload[:bytesRead]) + if err != nil { + return nil, fmt.Errorf("[%s] %w", r.decrypter.info(), err) + } + + return decryptedPayload, nil + } + + return payload[:bytesRead], nil +} + +type writerCtx struct { + ctx context.Context + writer io.Writer +} + +func (w *writerCtx) Write(p []byte) (int, error) { + if err := w.ctx.Err(); err != nil { + return 0, fmt.Errorf("context error: %w", err) + } + + n, err := w.writer.Write(p) + if err != nil { + return n, fmt.Errorf("write error: %w", err) + } + + return n, nil +} + +func NewStreamWriter(ctx context.Context, writer io.Writer) io.Writer { + return &writerCtx{ctx: ctx, writer: writer} +}