diff --git a/cmd/redirectord/redirectord.go b/cmd/redirectord/redirectord.go index 7fdb59d40f1a0207aeb0f7f6e27c0bf96947d4bb..79135adab16495351fde3c8b651c865759f330b7 100644 --- a/cmd/redirectord/redirectord.go +++ b/cmd/redirectord/redirectord.go @@ -5,6 +5,8 @@ import ( "fmt" "log" + _ "net/http/pprof" + "git.autistici.org/ale/autoradio" "git.autistici.org/ale/autoradio/fe" "git.autistici.org/ale/autoradio/instrumentation" @@ -36,10 +38,10 @@ func main() { client := autoradio.NewClient(autoradio.NewEtcdClient(false)) - dnsRed := fe.NewDnsRedirector(client, *domain, util.IPListWithDefault(*publicIps, "127.0.0.1"), dnsTtl) - dnsRed.Run(fmt.Sprintf(":%d", *dnsPort)) + dnsRed := fe.NewDNSRedirector(client, *domain, util.IPListWithDefault(*publicIps, "127.0.0.1"), dnsTtl) + dnsRed.Start(fmt.Sprintf(":%d", *dnsPort)) - red, err := fe.NewHttpRedirector(client, *domain, *lbPolicy, *staticDir, *templateDir) + red, err := fe.NewHTTPRedirector(client, *domain, *lbPolicy, *staticDir, *templateDir) if err != nil { log.Fatal(err) } diff --git a/fe/common.go b/fe/common.go index 367b5f4f834ae00cd0dd4d0ecf2b36ee7c8093b7..e2886284ec400a4e07fd9b253907d237f9e9b1f8 100644 --- a/fe/common.go +++ b/fe/common.go @@ -7,7 +7,7 @@ import ( ) // Filter a list of IP addresses by protocol. -func filterIpByProto(ips []net.IP, v6 bool) []net.IP { +func filterIPByProto(ips []net.IP, v6 bool) []net.IP { var candidates []net.IP for _, ip := range ips { isIPv6 := (ip.To4() == nil) @@ -19,8 +19,8 @@ func filterIpByProto(ips []net.IP, v6 bool) []net.IP { } // Pick a random IP for the specified proto. -func randomIpByProto(ips []net.IP, v6 bool) net.IP { - candidates := filterIpByProto(ips, v6) +func randomIPByProto(ips []net.IP, v6 bool) net.IP { + candidates := filterIPByProto(ips, v6) if len(candidates) > 0 { return candidates[rand.Intn(len(candidates))] } diff --git a/fe/dns.go b/fe/dns.go index 14969c27fd40bc418cd6a9bada7b453e4a0eaf0b..7977bd2ca1619cd56956de977b592c4bdc96e534 100644 --- a/fe/dns.go +++ b/fe/dns.go @@ -15,7 +15,7 @@ import ( var ( // Max number of results for an A query. - maxResults = 4 + maxResults = 3 // The names that we are serving. Currently, all services are // mapped to all the active nodes in the cluster. @@ -30,8 +30,8 @@ var ( dnsTargetStats = instrumentation.NewCounter("dns.target") ) -// DNS server. -type DnsRedirector struct { +// DNSRedirector sends clients to backends using DNS. +type DNSRedirector struct { client *autoradio.Client origin string originNumParts int @@ -40,9 +40,9 @@ type DnsRedirector struct { soa dns.RR } -// NewDnsRedirector returns a DNS server for the given origin and +// NewDNSRedirector returns a DNS server for the given origin and // publicIp. The A records served will have the specified ttl. -func NewDnsRedirector(client *autoradio.Client, origin string, publicIps []net.IP, ttl int) *DnsRedirector { +func NewDNSRedirector(client *autoradio.Client, origin string, publicIps []net.IP, ttl int) *DNSRedirector { if !strings.HasSuffix(origin, ".") { origin += "." } @@ -64,7 +64,7 @@ func NewDnsRedirector(client *autoradio.Client, origin string, publicIps []net.I Minttl: uint32(ttl), } - return &DnsRedirector{ + return &DNSRedirector{ client: client, origin: origin, originNumParts: len(dns.SplitDomainName(origin)), @@ -104,26 +104,15 @@ func ednsFromRequest(req, m *dns.Msg) { return } -func (d *DnsRedirector) withOrigin(name string) string { +func (d *DNSRedirector) withOrigin(name string) string { if name == "" { return d.origin } return name + "." + d.origin } -// Create an A RR for a specific IP. -func (d *DnsRedirector) recordForIp(name string, ip net.IP, v6 bool) dns.RR { - if v6 { - return &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: d.withOrigin(name), - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: uint32(d.ttl), - }, - AAAA: ip, - } - } +// Create an A resource record. +func (d *DNSRedirector) newA(name string, ip net.IP) dns.RR { return &dns.A{ Hdr: dns.RR_Header{ Name: d.withOrigin(name), @@ -135,8 +124,21 @@ func (d *DnsRedirector) recordForIp(name string, ip net.IP, v6 bool) dns.RR { } } +// Create an AAAA resource record. +func (d *DNSRedirector) newAAAA(name string, ip net.IP) dns.RR { + return &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: d.withOrigin(name), + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: uint32(d.ttl), + }, + AAAA: ip, + } +} + // Strip the origin from the query. -func (d *DnsRedirector) getQuestionName(req *dns.Msg) string { +func (d *DNSRedirector) getQuestionName(req *dns.Msg) string { lx := dns.SplitDomainName(req.Question[0].Name) ql := lx[0 : len(lx)-d.originNumParts] return strings.ToLower(strings.Join(ql, ".")) @@ -151,7 +153,7 @@ func flattenIPs(nodes []*autoradio.NodeStatus) []net.IP { return ips } -func (d *DnsRedirector) serveDNS(w dns.ResponseWriter, req *dns.Msg) { +func (d *DNSRedirector) serveDNS(w dns.ResponseWriter, req *dns.Msg) { m := new(dns.Msg) // Just NACK ANYs @@ -184,7 +186,10 @@ func (d *DnsRedirector) serveDNS(w dns.ResponseWriter, req *dns.Msg) { // Serve all active nodes on every request. We don't // really care about errors from GetNodes as long as // some nodes are returned (i.e. stale data from the - // cache is accepted). + // cache is accepted). Also, we need to filter the + // resulting list for nodes whose IP address protocol + // version matches the request type (IPv4 for A + // requests, IPv6 for AAAA). var ips []net.IP nodes, _ := d.client.GetNodes() if len(nodes) > 0 { @@ -197,7 +202,7 @@ func (d *DnsRedirector) serveDNS(w dns.ResponseWriter, req *dns.Msg) { ips = d.publicIps } isV6 := (req.Question[0].Qtype == dns.TypeAAAA) - ips = filterIpByProto(ips, isV6) + ips = filterIPByProto(ips, isV6) // Shuffle the list in random order, and keep only the // first N results. @@ -209,7 +214,12 @@ func (d *DnsRedirector) serveDNS(w dns.ResponseWriter, req *dns.Msg) { m.SetReply(req) m.MsgHdr.Authoritative = true for _, ip := range ips { - rec := d.recordForIp(query, ip, isV6) + var rec dns.RR + if isV6 { + rec = d.newAAAA(query, ip) + } else { + rec = d.newA(query, ip) + } m.Answer = append(m.Answer, rec) dnsTargetStats.IncrVar(ipToMetric(ip)) } @@ -233,9 +243,9 @@ func (d *DnsRedirector) serveDNS(w dns.ResponseWriter, req *dns.Msg) { w.WriteMsg(m) } -// Run starts the DNS servers on the given address (both tcp and udp). +// Start the DNS servers on the given address (both tcp and udp). // It creates new goroutines and returns immediately. -func (d *DnsRedirector) Run(addr string) { +func (d *DNSRedirector) Start(addr string) { dns.HandleFunc(d.origin, func(w dns.ResponseWriter, r *dns.Msg) { d.serveDNS(w, r) }) diff --git a/fe/doc.go b/fe/doc.go index 6be3e4083d51d1ebf4bad1b70a3617710fd4e468..43fc7b2f7bcf50e1af1e3dca64addc5c1ee4bf7e 100644 --- a/fe/doc.go +++ b/fe/doc.go @@ -1,37 +1,84 @@ -// The front-end ('fe') code has the purpose of directing user traffic -// where we want it: that is, on a node that is alive and (possibly) -// not overloaded. We do this at two different levels, DNS and HTTP, -// with two slightly different targets: the former is focused on -// availability, while the latter attempts to evenly distribute -// resource usage. -// -// DNS is used to provide at least one address of an active server: -// the capability to return multiple results, and the high-ttl nature -// of the service mean that we can simply return all the active nodes -// on every request, maximizing the chances that at least one of them -// will be active over a longer period of time. -// -// HTTP requests are istantaneous, and we can't rely on the client -// doing retries, so we must point the user at a single, active node -// on every request. There are two different policies, depending on -// the type of the request: -// -// - SOURCE requests must always reach the current master node. Since -// redirects tend to confuse streaming sources, which might have very -// simple HTTP implementations, we simply proxy the stream to the -// master node. -// -// - listener requests must be routed to an available relay, taking -// utilization into account (be it in terms of bandwidth, cpu usage, -// or more). The fact that the DNS layer returns multiple addresses -// provides already a very rough form of load balancing, but for -// accurate bandwidth planning we can't just rely on clients' -// cooperation. So when a client requests a stream using its public -// URL we need to serve a redirect to the desired node, computed -// according to the load balancing policy. This is currently done by -// serving a M3U file pointing directly at the target node's icecast -// daemon (but this may lock clients to that specific target node on -// failure... client reconnection policies still need some -// investigation). -// +/* + +Package fe contains the front-end (directly user facing) code for +autoradio. + +The front-end code has the purpose of directing user traffic where we +want it: that is, on a node that is alive and (possibly) not +overloaded. We do this at two different levels, DNS and HTTP, with two +slightly different targets: the former is focused on availability, +while the latter attempts to evenly distribute resource usage. + +Request Flow + +DNS is used to provide clients with at least one address of an active +server: we can simply return a subset of the active nodes on every +request, maximizing the chances that at least one of them will be +active over a longer period of time. + +HTTP requests are istantaneous, and clients usually do not implement a +retry behavior, so we must point the user at a single, active node on +every request. There are two different policies, depending on the type +of the request: + +- SOURCE requests must always reach the current master node. Since +redirects tend to confuse streaming sources, which might have very +simple HTTP implementations, we simply proxy the stream to the master +node. + +- Listener requests must be routed to an available relay, taking +utilization into account (be it in terms of bandwidth, cpu usage, or +more). The fact that the DNS layer returns multiple addresses provides +already a very rough form of load balancing, but for accurate +bandwidth planning we can't just rely on clients' cooperation. So when +a client requests a stream using its public URL we need to serve a +redirect to the desired node, computed according to the load balancing +policy. This is currently done by serving a M3U file pointing directly +at the target node's icecast daemon (but this may lock clients to that +specific target node on failure... client reconnection policies still +need some investigation). + +URLs + +Autoradio uses a few different types of URLs when interacting with +clients, with different scopes and purposes. For instance, with a +mount named "/test.ogg", we would have: + +The main public URL for the stream: + + http://stream.${DOMAIN}/test.ogg. + +It's a permanent URL that will always be valid. Clients will use +this URL to listen to the stream. M3U files must contain this URL. + +The response from the previous URL will be a redirect to a specific IP: + + http://${IP}/_stream/test.ogg + +This is just a proxy to the Icecast daemon running on that machine. +The chosen IP is either one chosen by the load balancing algorithm, if +the request is a GET from a client, or the current master in case of a +SOURCE request. + +Assumptions + +There are some fundamental assumptions in the traffic model presented +here, which are not entirely true in the real world: + +1) Clients are able to follow HTTP redirects: + +Browsers apparently do (though they try really hard to cache the +redirects, which we do not want), but a number of old streaming radio +clients probably don't. Some old streaming audio libraries have really +bare-bones HTTP client implementations. This problem appears to be +even worse for sources. + +2) Clients (or users) will retry if the connection is interrupted: + +Autoradio servers will usually drop connections on most +cluster-level events, so it is expected that clients will retry and +reconnect. Most clients don't do anything like that unfortunately, +but perhaps users will (if the stream suddenly stops). + +*/ package fe diff --git a/fe/http.go b/fe/http.go index a860c9af181a526400f77cacfe5814053d6f14e5..d07df1687e402ef1393d82c4d2d4606171a4322b 100644 --- a/fe/http.go +++ b/fe/http.go @@ -10,15 +10,12 @@ import ( "net" "net/http" "net/url" - "path" "path/filepath" "sort" "strconv" "strings" "time" - _ "net/http/pprof" - "git.autistici.org/ale/autoradio" "git.autistici.org/ale/autoradio/Godeps/_workspace/src/github.com/PuerkitoBio/ghost/handlers" "git.autistici.org/ale/autoradio/instrumentation" @@ -38,7 +35,8 @@ var ( ) // ResponseWriter wrapper that logs an entry for every incoming HTTP -// request, as soon as the response headers are sent. +// request, as soon as the response headers are sent. This makes more +// sense for long-standing connections like in our case. type logResponseWriter struct { http.ResponseWriter @@ -91,8 +89,11 @@ func logHandler(h http.Handler) http.HandlerFunc { } } -// HTTP redirector. -type HttpRedirector struct { +// HTTPRedirector makes clients talk to Icecast. This can happen +// either by sending HTTP redirects (if you want Icecast to be +// directly reachable from the outside), or by proxying the +// connections (and use just one open port). +type HTTPRedirector struct { domain string staticDir string lb *autoradioLoadBalancer @@ -100,7 +101,8 @@ type HttpRedirector struct { template *template.Template } -func NewHttpRedirector(client *autoradio.Client, domain, lbspec, staticDir, templateDir string) (*HttpRedirector, error) { +// NewHTTPRedirector creates a new HTTP redirector. +func NewHTTPRedirector(client *autoradio.Client, domain, lbspec, staticDir, templateDir string) (*HTTPRedirector, error) { lb, err := parseLoadBalancerSpec(lbspec) if err != nil { return nil, err @@ -108,7 +110,7 @@ func NewHttpRedirector(client *autoradio.Client, domain, lbspec, staticDir, temp tmpl := template.Must( template.ParseGlob( filepath.Join(templateDir, "*.html"))) - return &HttpRedirector{ + return &HTTPRedirector{ client: client, domain: domain, lb: lb, @@ -119,10 +121,10 @@ func NewHttpRedirector(client *autoradio.Client, domain, lbspec, staticDir, temp // Pick a random IP with a protocol appropriate to the request (based // on the remote address). -func randomIpForRequest(ips []net.IP, r *http.Request) net.IP { +func randomIPForRequest(ips []net.IP, r *http.Request) net.IP { remoteAddr := net.ParseIP(r.RemoteAddr) isV6 := (remoteAddr != nil && (remoteAddr.To4() == nil)) - return randomIpByProto(ips, isV6) + return randomIPByProto(ips, isV6) } type httpRequestContext struct { @@ -135,15 +137,15 @@ func (r *httpRequestContext) RemoteAddr() net.IP { // Return an active node, chosen according to the current load // balancing policy. -func (h *HttpRedirector) pickActiveNode(r *http.Request) net.IP { +func (h *HTTPRedirector) pickActiveNode(r *http.Request) net.IP { result := h.lb.Choose(&httpRequestContext{r}) if result == nil { return nil } - return randomIpForRequest(result.IP, r) + return randomIPForRequest(result.IP, r) } -func (h *HttpRedirector) lbUpdater() { +func (h *HTTPRedirector) lbUpdater() { for range time.NewTicker(2 * time.Second).C { nodes, err := h.client.GetNodes() if err != nil { @@ -157,7 +159,7 @@ func icecastAddr(server net.IP) string { return net.JoinHostPort(server.String(), strconv.Itoa(autoradio.IcecastPort)) } -func streamUrl(server net.IP, mountName string) string { +func streamURL(server net.IP, mountName string) string { var serverAddr string if *proxyStreams { serverAddr = server.String() @@ -168,7 +170,7 @@ func streamUrl(server net.IP, mountName string) string { } // Request wrapper that passes a Mount along with the HTTP request. -func (h *HttpRedirector) withMount(f func(*autoradio.Mount, http.ResponseWriter, *http.Request)) http.Handler { +func (h *HTTPRedirector) withMount(f func(*autoradio.Mount, http.ResponseWriter, *http.Request)) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mountPath := strings.TrimSuffix(r.URL.Path, ".m3u") mount, err := h.client.GetMount(mountPath) @@ -182,7 +184,7 @@ func (h *HttpRedirector) withMount(f func(*autoradio.Mount, http.ResponseWriter, // Serve a M3U response. This simply points back at the stream // redirect handler. -func (h *HttpRedirector) serveM3U(mount *autoradio.Mount, w http.ResponseWriter, r *http.Request) { +func (h *HTTPRedirector) serveM3U(mount *autoradio.Mount, w http.ResponseWriter, r *http.Request) { m3u := strings.TrimSuffix(r.URL.String(), ".m3u") + "\n" w.Header().Set("Content-Length", strconv.Itoa(len(m3u))) w.Header().Set("Content-Type", "audio/x-mpegurl") @@ -190,48 +192,8 @@ func (h *HttpRedirector) serveM3U(mount *autoradio.Mount, w http.ResponseWriter, io.WriteString(w, m3u) } -// redirect replies to the request with a redirect to url, adding some -// cache-busting headers. Code is mostly verbatim from net/http. -// Serve a 307 for HTTP/1.1 clients, a 302 otherwise. -func redirect(w http.ResponseWriter, r *http.Request, urlStr string) { - if u, err := url.Parse(urlStr); err == nil { - oldpath := r.URL.Path - if oldpath == "" { - oldpath = "/" - } - if u.Scheme == "" { - if urlStr == "" || urlStr[0] != '/' { - olddir, _ := path.Split(oldpath) - urlStr = olddir + urlStr - } - - var query string - if i := strings.Index(urlStr, "?"); i != -1 { - urlStr, query = urlStr[:i], urlStr[i:] - } - - trailing := strings.HasSuffix(urlStr, "/") - urlStr = path.Clean(urlStr) - if trailing && !strings.HasSuffix(urlStr, "/") { - urlStr += "/" - } - urlStr += query - } - } - - w.Header().Set("Location", urlStr) - w.Header().Set("Cache-Control", "max-age=0,no-cache,no-store") - w.Header().Set("Pragma", "no-cache") - w.Header().Set("Expires", "-1") - code := 302 - if r.ProtoMinor == 1 { - code = 307 - } - w.WriteHeader(code) -} - // Serve a response for a client connection to a relay. -func (h *HttpRedirector) serveRelay(mount *autoradio.Mount, w http.ResponseWriter, r *http.Request) { +func (h *HTTPRedirector) serveRelay(mount *autoradio.Mount, w http.ResponseWriter, r *http.Request) { // Find an active node. relayAddr := h.pickActiveNode(r) if relayAddr == nil { @@ -244,13 +206,25 @@ func (h *HttpRedirector) serveRelay(mount *autoradio.Mount, w http.ResponseWrite if strings.HasSuffix(r.URL.Path, ".m3u") { h.serveM3U(mount, w, r) } else { - targetURL := streamUrl(relayAddr, mount.Name) - redirect(w, r, targetURL) + targetURL := streamURL(relayAddr, mount.Name) + + // Firefox apparently caches redirects regardless of + // the status code, so we have to add some quite + // aggressive cache-busting headers. We serve a status + // code of 307 to HTTP/1.1 clients, 302 otherwise. + w.Header().Set("Cache-Control", "max-age=0,no-cache,no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "-1") + code := 302 + if r.ProtoMinor == 1 { + code = 307 + } + http.Redirect(w, r, targetURL, code) } } // Handle SOURCE requests. -func (h *HttpRedirector) serveSource(mount *autoradio.Mount, w http.ResponseWriter, r *http.Request) { +func (h *HTTPRedirector) serveSource(mount *autoradio.Mount, w http.ResponseWriter, r *http.Request) { if mount.IsRelay() { log.Printf("source: connection to relay stream %s", mount.Name) http.Error(w, "Stream is relayed", http.StatusBadRequest) @@ -316,7 +290,7 @@ func (l mountStatusList) Less(i, j int) bool { } // Serve our cluster status page. -func (h *HttpRedirector) serveStatusPage(w http.ResponseWriter, r *http.Request) { +func (h *HTTPRedirector) serveStatusPage(w http.ResponseWriter, r *http.Request) { nodes, _ := h.client.GetNodes() mounts, _ := h.client.ListMounts() @@ -384,7 +358,7 @@ func withLocalhost(h http.Handler) http.Handler { }) } -func (h *HttpRedirector) createHandler() http.Handler { +func (h *HTTPRedirector) createHandler() http.Handler { // Create our HTTP handler stack. mux := http.NewServeMux() @@ -444,7 +418,7 @@ func (h *HttpRedirector) createHandler() http.Handler { } // Run starts the HTTP server on the given addr. Does not return. -func (h *HttpRedirector) Run(addr string) { +func (h *HTTPRedirector) Run(addr string) { // Start the background goroutine that updates the // LoadBalancer asynchronously. go h.lbUpdater() @@ -462,5 +436,5 @@ func (h *HttpRedirector) Run(addr string) { func addDefaultHeaders(w http.ResponseWriter) { w.Header().Set("Expires", "-1") - w.Header().Set("Cache-Control", "private, max-age=0") + w.Header().Set("Cache-Control", "no-store") } diff --git a/fe/http_test.go b/fe/http_test.go index f004c666a03e7a0427224ba68cc379a24e3228ad..86558f5075fe0117f1deac1f5b2a97c2bd75ef40 100644 --- a/fe/http_test.go +++ b/fe/http_test.go @@ -25,7 +25,7 @@ func createTestTargetServer(t *testing.T) (*httptest.Server, int) { return srv, port } -func createTestHttpRedirector(t *testing.T) *HttpRedirector { +func createTestHTTPRedirector(t *testing.T) *HTTPRedirector { nodes := []*autoradio.NodeStatus{ &autoradio.NodeStatus{ Name: "node1", @@ -48,7 +48,7 @@ func createTestHttpRedirector(t *testing.T) *HttpRedirector { `{"Name": "node1", "IP": ["127.0.0.1"]}`, 86400) client := autoradio.NewClient(etcd) - h, err := NewHttpRedirector(client, "example.com", "best", "./static", "./templates") + h, err := NewHTTPRedirector(client, "example.com", "best", "./static", "./templates") if err != nil { t.Fatal(err) } @@ -59,8 +59,8 @@ func createTestHttpRedirector(t *testing.T) *HttpRedirector { return h } -func createTestHttpServer(t *testing.T) (*HttpRedirector, *httptest.Server) { - h := createTestHttpRedirector(t) +func createTestHttpServer(t *testing.T) (*HTTPRedirector, *httptest.Server) { + h := createTestHTTPRedirector(t) srv := httptest.NewServer(h.createHandler()) return h, srv } @@ -86,7 +86,7 @@ func doHttpRequest(t *testing.T, method, url string, expectedStatus int) string return string(data) } -func TestHttpRedirector_StatusPage(t *testing.T) { +func TestHTTPRedirector_StatusPage(t *testing.T) { _, srv := createTestHttpServer(t) defer srv.Close() @@ -106,7 +106,7 @@ func TestHttpRedirector_StatusPage(t *testing.T) { } } -func TestHttpRedirector_Static(t *testing.T) { +func TestHTTPRedirector_Static(t *testing.T) { _, srv := createTestHttpServer(t) defer srv.Close() @@ -116,7 +116,7 @@ func TestHttpRedirector_Static(t *testing.T) { } } -func TestHttpRedirector_LBDebugPage(t *testing.T) { +func TestHTTPRedirector_LBDebugPage(t *testing.T) { _, srv := createTestHttpServer(t) defer srv.Close() @@ -153,7 +153,7 @@ func createTestHttpContext(t *testing.T) *httpTestContext { return c } -func TestHttpRedirector_Source(t *testing.T) { +func TestHTTPRedirector_Source(t *testing.T) { ctx := createTestHttpContext(t) defer ctx.Close() @@ -165,7 +165,7 @@ func TestHttpRedirector_Source(t *testing.T) { doHttpRequest(t, "SOURCE", ctx.srv.URL+"/nonexist.ogg", 404) } -func TestHttpRedirector_Relay(t *testing.T) { +func TestHTTPRedirector_Relay(t *testing.T) { ctx := createTestHttpContext(t) defer ctx.Close() @@ -175,7 +175,7 @@ func TestHttpRedirector_Relay(t *testing.T) { } } -func TestHttpRedirector_IcecastProxy(t *testing.T) { +func TestHTTPRedirector_IcecastProxy(t *testing.T) { *proxyStreams = true defer func() { *proxyStreams = false diff --git a/fe/templates/index.html b/fe/templates/index.html index 71ae9b2e9af2aaa1a74c4a1baaa973350a7ab22c..b5d04f94905331e14c4b6f539ac04b6b9db2d820 100644 --- a/fe/templates/index.html +++ b/fe/templates/index.html @@ -3,7 +3,7 @@ <head> <title>stream.{{.Domain}}</title> <link rel="stylesheet" - href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"> + href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/style.css"> <link rel="shortcut icon" href="/static/radio52.png"> </head>