Reverse Proxy with SSL support, Generated client Configs, JWT client to server auth, closes #13

This commit is contained in:
2018-02-07 21:41:00 -05:00
parent 0abe1620c6
commit d6288f4aaa
17 changed files with 1816 additions and 1222 deletions

2
.gitignore vendored
View File

@@ -18,4 +18,6 @@ logs/server.log
.goreleaser.yml
config.toml.backup
/public/static/js/kickwebsocket.js.backup
/public/static/js/kickwebsocket-generated.js
clientAuth.txt
dist

View File

@@ -39,6 +39,12 @@ Image of the frontend UI
- [X] Add torrents from watch folder (cron job every 5 minutes)
- [X] Authentication from client to server (done via JWT, will add functionality for 3rd party clients later)
- [X] Reverse Proxy Support with SSL upgrade added (with provided config for nginx)
- [X] Mostly generated client config from toml.config on first run
- [ ] Unit testing completed for a large portion of the package
- [ ] Stability/bug fixing/Optimization rewrite of some of the core structures of the WebUI and base server
@@ -49,7 +55,6 @@ Image of the frontend UI
- [ ] Ability to view TOML settings from WebUI (and perhaps change a few as well)
- [ ] Authentication from client to server
- Late 2018

View File

@@ -1,12 +1,12 @@
[serverConfig]
ServerPort = ":8000" #leave format as is it expects a string with colon
ServerAddr = "" #blank will bind to default IP address, usually fine to leave be
LogLevel = "Warn" # Options = Debug, Info, Warn, Error, Fatal, Panic
LogOutput = "file" #Options = file, stdout #file will print it to logs/server.log
LogLevel = "Debug" # Options = Debug, Info, Warn, Error, Fatal, Panic
LogOutput = "stdout" #Options = file, stdout #file will print it to logs/server.log
SeedRatioStop = 1.50 #automatically stops the torrent after it reaches this seeding ratio
#Relative or absolute path accepted, the server will convert any relative path to an absolute path.
DefaultMoveFolder = 'downloaded' #default path that a finished torrent is symlinked to after completion. Torrents added via RSS will default here
TorrentWatchFolder = 'torrentUpload' #folder path that is watched for .torrent files and adds them automatically every 5 minutes
@@ -17,18 +17,30 @@
DownloadRateLimit = "Unlimited"
[notifications]
PushBulletToken = "" #add your pushbullet api token here to notify of torrent completion to pushbullet
[goTorrentWebUI]
#Here you can set a basic HTTP login set of credentials
WebUIAuth = true # bool, if false no authentication is required for the webUI
WebUIUser = "admin"
WebUIPassword = "Password"
[reverseProxy]
#This is for setting up goTorrent behind a reverse Proxy (with SSL, reverse proxy with no SSL will require editing the WSS connection to a WS connection manually)
ProxyEnabled = false #bool, either false or true
BaseURL = "yoursubdomain.domain.org/subroute/" # MUST be in the format (if you have a subdomain, and must have trailing slash) "yoursubdomain.domain.org/subroute/"
[EncryptionPolicy]
DisableEncryption = false
ForceEncryption = false
PreferNoEncryption = true
[torrentClientConfig]
DownloadDir = 'downloading' #the full OR relative path where the torrent server stores in-progress torrents
@@ -37,6 +49,9 @@
# Never send chunks to peers.
NoUpload = false #boolean
#User-provided Client peer ID. If not present, one is generated automatically.
PeerID = "" #string
#The address to listen for new uTP and TCP bittorrent protocol connections. DHT shares a UDP socket with uTP unless configured otherwise.
ListenAddr = "" #Leave Blank for default, syntax "HOST:PORT"
@@ -48,9 +63,6 @@
# Don't create a DHT.
NoDHT = false #boolean
#User-provided Client peer ID. If not present, one is generated automatically.
PeerID = "" #string
#For the bittorrent protocol.
DisableUTP = false #bool

View File

@@ -0,0 +1,12 @@
location ^~ /gotorrent/ {
proxy_pass http://192.168.1.100:8000/;
proxy_redirect http:// https://;
proxy_pass_header Server;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_address;
proxy_set_header X-Scheme $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}

View File

@@ -0,0 +1,60 @@
package engine
import (
"crypto/rand"
"math/big"
"github.com/dgrijalva/jwt-go"
"github.com/sirupsen/logrus"
)
type AuthRequest struct {
MessageType string `json:"MessageType"`
AuthString string `json:"AuthString"`
}
//GoTorrentClaims stores the name of the client (usually user entered) and any standard jwt claims we want to define
type GoTorrentClaims struct {
ClientName string `json:"clientName"`
jwt.StandardClaims
}
//GenerateToken creates a signed token for a client to use to communicate with the server
func GenerateToken(claims GoTorrentClaims, signingKey []byte) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedString, err := token.SignedString(signingKey)
if err != nil {
Logger.WithFields(logrus.Fields{"error": err}).Fatal("Error signing authentication Token!")
}
return signedString
}
//GenerateSigningKey creates a random key that will be used for JSON Web Token authentication
func GenerateSigningKey() []byte {
keyString, err := generateRandomASCIIString(24)
key := []byte(keyString)
if err != nil {
Logger.WithFields(logrus.Fields{"error": err}).Fatal("Error generating signing key!")
}
return key
}
func generateRandomASCIIString(length int) (string, error) {
result := ""
for {
if len(result) >= length {
return result, nil
}
num, err := rand.Int(rand.Reader, big.NewInt(int64(127)))
if err != nil {
return "", err
}
n := num.Int64()
// Make sure that the number/byte/letter is inside
// the range of printable ASCII characters (excluding space and DEL)
if n > 32 && n < 127 {
result += string(n)
}
}
}

View File

@@ -0,0 +1,50 @@
package engine
import (
"io/ioutil"
"os"
)
var (
baseFile = `
var authMessage = {
MessageType: "authRequest",
Payload: [ClientAuthString]
}
var kickStart = {
MessageType: "torrentListRequest"
}
ws.onopen = function()
{
ws.send(JSON.stringify(authMessage));
console.log("Sending authentication message...", JSON.stringify(authMessage))
ws.send(JSON.stringify(kickStart)); //sending out the first ping
console.log("Kicking off websocket to server.....", JSON.stringify(kickStart))
};`
)
//GenerateClientConfigFile runs at first run (no db client detected) to generate a js file for connecting
func GenerateClientConfigFile(config FullClientSettings, authString string) {
os.Remove("public/static/js/kickwebsocket-generated.js")
var clientFile string
if config.UseProxy {
clientFile = `
ClientAuthString = "` + authString + `"
var ws = new WebSocket("wss://` + config.BaseURL + `websocket")
` + baseFile
} else {
clientFile = `
IP = "` + config.HTTPAddrIP + `"
Port = "` + config.WebsocketClientPort + `"
ClientAuthString = "` + authString + `"
var ws = new WebSocket(` + "`" + `ws://${IP}:${Port}/websocket` + "`" + `); //creating websocket
` + baseFile
}
clientFileBytes := []byte(clientFile)
ioutil.WriteFile("public/static/js/kickwebsocket-generated.js", clientFileBytes, 0755)
}

View File

@@ -41,7 +41,7 @@ type RSSFeedsNames struct {
}
//TorrentList struct contains the torrent list that is sent to the client
type TorrentList struct { //helps create the JSON structure that react expects to recieve
type TorrentList struct { //helps create the JSON structure that react expects to receive
MessageType string `json:"MessageType"`
Totaltorrents int `json:"total"`
ClientDBstruct []ClientDB `json:"data"`

View File

@@ -3,6 +3,7 @@ package engine
import (
"fmt"
"path/filepath"
"strings"
"golang.org/x/time/rate"
@@ -17,6 +18,10 @@ type FullClientSettings struct {
LoggingLevel logrus.Level
LoggingOutput string
HTTPAddr string
HTTPAddrIP string
UseProxy bool
WebsocketClientPort string
BaseURL string
Version int
TorrentConfig torrent.Config
TFileUploadFolder string
@@ -98,9 +103,17 @@ func FullClientSettingsNew() FullClientSettings {
}
var httpAddr string
var baseURL string
var websocketClientPort string
httpAddrIP := viper.GetString("serverConfig.ServerAddr")
httpAddrPort := viper.GetString("serverConfig.ServerPort")
proxySet := viper.GetBool("reverseProxy.ProxyEnabled")
websocketClientPort = strings.TrimLeft(viper.GetString("serverConfig.ServerPort"), ":") //Trimming off the colon in front of the port
if proxySet {
baseURL = viper.GetString("reverseProxy.BaseURL")
fmt.Println("WebsocketClientPort", viper.GetString("serverConfig.ServerPort"))
}
seedRatioStop := viper.GetFloat64("serverConfig.SeedRatioStop")
httpAddr = httpAddrIP + httpAddrPort
pushBulletToken := viper.GetString("notifications.PushBulletToken")
@@ -196,7 +209,11 @@ func FullClientSettingsNew() FullClientSettings {
LoggingOutput: logOutput,
SeedRatioStop: seedRatioStop,
HTTPAddr: httpAddr,
HTTPAddrIP: httpAddrIP,
UseProxy: proxySet,
WebsocketClientPort: websocketClientPort,
TorrentConfig: tConfig,
BaseURL: baseURL,
TFileUploadFolder: "uploadedTorrents",
PushBulletToken: pushBulletToken,
DefaultMoveFolder: defaultMoveFolderAbs,

View File

@@ -18,6 +18,8 @@ import LeftMenu from './leftMenu/leftMenu';
import TorrentList from './torrentlist';
//Notification Element
import Notifications from './notifications';
//Login Box
import Login from './login';
@@ -65,6 +67,7 @@ class BasicLayout extends React.PureComponent {
render() {
return [
<Login />,
<Notifications />,
<ReactGridLayout layout={this.state.layout} onLayoutChange={this.onLayoutChange}
{...this.props}>

113
goTorrentWebUI/src/login.js Normal file
View File

@@ -0,0 +1,113 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Button from 'material-ui/Button';
import TextField from 'material-ui/TextField';
import { withStyles } from 'material-ui/styles';
import PropTypes from 'prop-types';
import Dialog, {
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from 'material-ui/Dialog';
import InsertLinkIcon from 'material-ui-icons/Link';
import ReactTooltip from 'react-tooltip'
import Icon from 'material-ui/Icon';
import IconButton from 'material-ui/IconButton';
let Loggedin = false
const button = {
fontSize: '60px',
marginRight: '20px',
}
const inlineStyle = {
display: 'inline-block',
backdrop: 'static',
}
const errorStyle = {
color: 'red',
}
export default class Login extends React.Component {
state = {
open: false,
username: "",
password: "",
wrongPasswordMessage: "",
};
componentWillMount = () => {
if ((LoginRequired) && (Loggedin == false)) {
this.setState({open: true})
Loggedin = true
}
}
handleSubmit = () => {
//this.setState({ open: false });
//let magnetLinkSubmit = this.state.textValue;
console.log("Attempting authentication")
if ((this.state.username == ClientUsername) && (this.state.password == ClientPassword)) {
this.setState({ open: false, username: "", password: "" });
} else {
this.setState({wrongPasswordMessage: "Wrong Username/Password!", username: "", password: "" })
}
//this.setState({magnetLinkValue: ""}, {torrentLabel: ""}, {storageValue: ``})
}
handleRequestClose = () => {
ws.close()
}
setUserNameValue = (event) => {
this.setState({username: event.target.value});
}
setPasswordValue = (event) => {
this.setState({password: event.target.value})
}
render() {
const { classes, onRequestClose, handleRequestClose, handleSubmit } = this.props;
return (
<Dialog open={this.state.open} onRequestClose={this.handleRequestClose} disableBackdropClick={true} disableEscapeKeyDown={true} hideBackdrop={true} fullScreen>
<DialogTitle>Login Here</DialogTitle>
<DialogContent>
<DialogContentText>
Enter a username and password to connect
</DialogContentText>
<br />
<DialogContentText style={errorStyle}>
{this.state.wrongPasswordMessage}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="name"
label="User Name"
type="text"
placeholder="Username"
fullWidth
onChange={this.setUserNameValue}
/>
<TextField id="password" type="password" label="Password" placeholder="Password" fullWidth onChange={this.setPasswordValue} />
</DialogContent>
<DialogActions>
<Button onClick={this.handleRequestClose} color="primary">
Cancel
</Button>
<Button onClick={this.handleSubmit} color="primary">
Submit
</Button>
</DialogActions>
</Dialog>
);
}
};

93
main.go
View File

@@ -6,7 +6,6 @@ import (
"fmt"
"html/template"
"io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -18,6 +17,8 @@ import (
"github.com/asdine/storm"
Engine "github.com/deranjer/goTorrent/engine"
Storage "github.com/deranjer/goTorrent/storage"
jwt "github.com/dgrijalva/jwt-go"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/mmcdole/gofeed"
@@ -36,6 +37,7 @@ type SingleRSSFeedMessage struct { //TODO had issues with getting this to work w
var (
//Logger does logging for the entire project
Logger = logrus.New()
Authenticated = false //to verify if user is authenticated, this is stored here
APP_ID = os.Getenv("APP_ID")
)
@@ -49,6 +51,34 @@ func serveHome(w http.ResponseWriter, r *http.Request) {
s1.ExecuteTemplate(w, "base", map[string]string{"APP_ID": APP_ID})
}
func handleAuthentication(conn *websocket.Conn, db *storm.DB) {
msg := Engine.Message{}
err := conn.ReadJSON(&msg)
if err != nil {
Logger.WithFields(logrus.Fields{"error": err, "SuppliedToken": msg.Payload[0]}).Error("Unable to read authentication message")
}
authString := msg.Payload[0] //First element will be the auth request
fmt.Println("Authstring", authString)
signingKeyStruct := Storage.FetchJWTTokens(db)
singingKey := signingKeyStruct.SigningKey
token, err := jwt.Parse(authString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return singingKey, nil
})
if err != nil {
Logger.WithFields(logrus.Fields{"error": err, "SuppliedToken": token}).Error("Unable to parse token!")
conn.Close()
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
fmt.Println("Claims", claims["ClientName"], claims["Issuer"])
Authenticated = true
} else {
Logger.WithFields(logrus.Fields{"error": err}).Error("Authentication Error occured, cannot complete!")
}
}
func main() {
Engine.Logger = Logger //Injecting the logger into all the packages
Storage.Logger = Logger
@@ -92,6 +122,34 @@ func main() {
}
defer db.Close() //defering closing the database until the program closes
tokens := Storage.IssuedTokensList{} //if first run setting up the authentication tokens
err = db.One("ID", 3, &tokens)
if err != nil {
Logger.WithFields(logrus.Fields{"RSSFeedStore": tokens, "error": err}).Info("No Tokens database found, assuming first run, generating token...")
fmt.Println("Error", err)
fmt.Println("MAIN TOKEN: %+v\n", tokens)
tokens.ID = 3 //creating the initial store
claims := Engine.GoTorrentClaims{
"goTorrentWebUI",
jwt.StandardClaims{
Issuer: "goTorrentServer",
},
}
signingkey := Engine.GenerateSigningKey() //Running this will invalidate any certs you already issued!!
fmt.Println("SigningKey", signingkey)
authString := Engine.GenerateToken(claims, signingkey)
tokens.SigningKey = signingkey
fmt.Println("ClientToken: ", authString)
Engine.GenerateClientConfigFile(Config, authString) //if first run generate the client config file
tokens.TokenNames = append(tokens.TokenNames, Storage.SingleToken{"firstClient"})
err := ioutil.WriteFile("clientAuth.txt", []byte(authString), 0755)
if err != nil {
Logger.WithFields(logrus.Fields{"error": err}).Warn("Unable to write client auth to file..")
}
db.Save(&tokens) //Writing all of that to the database
}
cronEngine := Engine.InitializeCronEngine() //Starting the cron engine for tasks
Logger.Debug("Cron Engine Initialized...")
@@ -113,10 +171,12 @@ func main() {
Engine.RefreshRSSCron(cronEngine, db, tclient, torrentLocalStorage, Config) // Refresing the RSS feeds on an hourly basis to add torrents that show up in the RSS feed
router := mux.NewRouter() //setting up the handler for the web backend
//reverseProxy := handlers.ProxyHeaders(router) //handlers.ProxyHeaders(router) //TODO pull this from the config file
router.HandleFunc("/", serveHome) //Serving the main page for our SPA
http.Handle("/static/", http.FileServer(http.Dir("public")))
//http.Handle("/static/", http.FileServer(http.Dir("public")))
router.PathPrefix("/static/").Handler(http.FileServer(http.Dir("public")))
http.Handle("/", router)
http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) { //exposing the data to the
router.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) { //exposing the data to the
TorrentLocalArray = Storage.FetchAllStoredTorrents(db)
RunningTorrentArray = Engine.CreateRunningTorrentArray(tclient, TorrentLocalArray, PreviousTorrentArray, Config, db) //Updates the RunningTorrentArray with the current client data as well
var torrentlistArray = new(Engine.TorrentList)
@@ -127,15 +187,21 @@ func main() {
w.Header().Set("Content-Type", "application/json")
w.Write(torrentlistArrayJSON)
})
http.HandleFunc("/websocket", func(w http.ResponseWriter, r *http.Request) { //websocket is the main data pipe to the frontend
router.HandleFunc("/websocket", func(w http.ResponseWriter, r *http.Request) { //websocket is the main data pipe to the frontend
conn, err := upgrader.Upgrade(w, r, nil)
defer conn.Close() //defer closing the websocket until done.
if err != nil {
Logger.WithFields(logrus.Fields{"error": err}).Fatal("Unable to create websocket!")
return
}
Engine.Conn = conn //Injecting the conn variable into the other packages
if Authenticated != true {
handleAuthentication(conn, db)
} else { //If we are authenticated inject the connection into the other packages
fmt.Println("Authenticated... continue")
Engine.Conn = conn
Storage.Conn = conn
}
MessageLoop: //Tagging this so we can continue out of it with any errors we encounter that are failing
for {
runningTorrents := tclient.Torrents() //getting running torrents here since multiple cases ask for the running torrents
@@ -148,6 +214,13 @@ func main() {
}
Logger.WithFields(logrus.Fields{"message": msg}).Debug("Message From Client")
switch msg.MessageType { //first handling data requests
case "authRequest":
if Authenticated {
Logger.WithFields(logrus.Fields{"message": msg}).Debug("Client already authenticated... skipping authentication method")
} else {
handleAuthentication(conn, db)
}
case "torrentListRequest":
Logger.WithFields(logrus.Fields{"message": msg}).Debug("Client Requested TorrentList Update")
TorrentLocalArray = Storage.FetchAllStoredTorrents(db) //Required to re-read th database since we write to the DB and this will pull the changes from it
@@ -491,9 +564,15 @@ func main() {
}
})
if err := http.ListenAndServe(httpAddr, nil); err != nil {
if Config.UseProxy {
err := http.ListenAndServe(httpAddr, handlers.ProxyHeaders(router))
if err != nil {
Logger.WithFields(logrus.Fields{"error": err}).Fatal("Unable to listen on the http Server!")
}
} else {
fmt.Println("Server started on:", httpAddr)
err := http.ListenAndServe(httpAddr, nil) //Can't send proxy headers if not used since that can be a security issue
if err != nil {
Logger.WithFields(logrus.Fields{"error": err}).Fatal("Unable to listen on the http Server with no proxy headers!")
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
//This is the basic template used to generate the kickwebsocket. If needed you can manually edit this one to your needs and replace kickwebsocket-generated.
IP = "192.168.1.100"
Port = "8000"
ClientAuthString = "" //String generated on first start and stored in the root as "clientAuth.txt"
//var ws = new WebSocket(`ws://${IP}:${Port}/websocket`); //for websockets not over an SSL reverse proxy
//var ws = new WebSocket("wss://yoursubdomain.domain.org/subroute/") //for websockets behind an SSL reverse proxy
var authMessage = {
MessageType: "authRequest",
Payload: [ClientAuthString]
}
var kickStart = {
MessageType: "torrentListRequest"
}
ws.onopen = function()
{
ws.send(JSON.stringify(authMessage));
console.log("Sending authentication message...", JSON.stringify(authMessage))
ws.send(JSON.stringify(kickStart)); //sending out the first ping
console.log("Kicking off websocket to server.....", JSON.stringify(kickStart))
};

View File

@@ -1,12 +0,0 @@
var ws = new WebSocket("ws://192.168.1.141:8000/websocket"); //creating websocket
var kickStart = {
MessageType: "torrentListRequest"
}
ws.onopen = function()
{
ws.send(JSON.stringify(kickStart)); //sending out the first ping
console.log("Kicking off websocket to server.....", JSON.stringify(kickStart))
};

BIN
storage.db.old Normal file

Binary file not shown.

View File

@@ -15,6 +15,24 @@ var Logger *logrus.Logger
//Conn is the global websocket connection used to push server notification messages
var Conn *websocket.Conn
//IssuedTokensList contains a slice of all the tokens issues to applications
type IssuedTokensList struct {
ID int `storm:"id,unique"` //storm requires unique ID (will be 3) to save although there will only be one of these
SigningKey []byte
TokenNames []SingleToken
}
//SingleToken stores a single token and all of the associated information
type SingleToken struct {
ClientName string
}
//TorrentHistoryList holds the entire history of downloaded torrents by hash TODO implement a way to read this and maybe grab the name for every torrent as well
type TorrentHistoryList struct {
ID int `storm:"id,unique"` //storm requires unique ID (will be 2) to save although there will only be one of these
HashList []string
}
//RSSFeedStore stores all of our RSS feeds in a slice of gofeed.Feed
type RSSFeedStore struct {
ID int `storm:"id,unique"` //storm requires unique ID (will be 1) to save although there will only be one of these
@@ -64,12 +82,6 @@ type TorrentLocal struct {
TorrentFilePriority []TorrentFilePriority
}
//TorrentHistoryList holds the entire history of downloaded torrents by hash TODO implement a way to read this and maybe grab the name for every torrent as well
type TorrentHistoryList struct {
ID int `storm:"id,unique"` //storm requires unique ID (will be 2) to save although there will only be one of these
HashList []string
}
//FetchAllStoredTorrents is called to read in ALL local stored torrents in the boltdb database (called on server restart)
func FetchAllStoredTorrents(torrentStorage *storm.DB) (torrentLocalArray []*TorrentLocal) {
torrentLocalArray = []*TorrentLocal{} //creating the array of the torrentlocal struct
@@ -188,6 +200,25 @@ func StoreHashHistory(db *storm.DB, torrentHash string) {
}
}
//FetchJWTTokens fetches the stored client authentication tokens
func FetchJWTTokens(db *storm.DB) IssuedTokensList {
tokens := IssuedTokensList{}
err := db.One("ID", 3, &tokens)
if err != nil {
Logger.WithFields(logrus.Fields{"Tokens": tokens, "error": err}).Error("Unable to fetch Token database... should always be one token in database")
}
return tokens
}
//UpdateJWTTokens updates the database with new tokens as they are added
func UpdateJWTTokens(db *storm.DB, tokens IssuedTokensList) {
err := db.Update(&tokens)
if err != nil {
Logger.WithFields(logrus.Fields{"Tokens": tokens, "error": err}).Error("Unable to update Token database")
}
}
//FetchRSSFeeds fetches the RSS feed from db, which was setup when initializing database on first startup
func FetchRSSFeeds(db *storm.DB) RSSFeedStore {
RSSFeed := RSSFeedStore{}

View File

@@ -3,13 +3,13 @@
<html lang="en">
<head>
<title>goTorrent</title>
<link rel="icon" href="/static/favicon/goTorrentFavicon.ico">
<script type="text/javascript" src="/static/js/kickwebsocket.js"></script>
<link rel="icon" href="static/favicon/goTorrentFavicon.ico">
<script type="text/javascript" src="static/js/kickwebsocket-generated.js"></script>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
</head>
<body>
<div id=app></div>
<script type="text/javascript" src="/static/js/bundle.js"></script>
<script type="text/javascript" src="static/js/bundle.js"></script>
</body>
</html>
{{end}}