mirror of
https://github.com/ossrs/srs.git
synced 2026-10-24 09:59:20 +03:00
Adds `srs-integ`, a black-box integration suite for the SRS media server, and fixes the SRS bugs it found: over 120 of them, in every protocol. The suite tests SRS from outside the process, only through what a client, an operator or a callback server can see, so a rewritten server could pass the same cases. The suite now has about 1,450 cases (1,353 main and 99 timer), with no known bugs left: every bug a case found is fixed in this PR. It also removes some unused features, adds env support for a few more directives, and changes a few hook and API fields. ## srs-integ suite `cmd/integ` is a Go module of its own, `srs-integ`, separate from the root `srsx` module. It imports nothing from the repository. - **Run:** `make integ` makes the test media, then runs the cases in two `go test` processes at once: the main cases and the `TestTimer_` cases. `make integ-unit` runs the unit tests of the clients and analyzers, and `bin/srs-integ` gives `probe`, `publish` and `prepare` from the command line. - **Server adapter:** each server is `objs/srs -e`, configured only by env vars, on ports from `SRS_INTEG_PORTS` (14000-14999 in the Port Plan). Most cases share a Common server, split into 4 shards. Cases that need other settings run on variant servers, and groups share these where they can. - **Case classes:** Fast (≤100ms), Normal (≤500ms) and Timer (≤10s, only in `TestTimer_` groups). A case that runs over its limit fails. `KnownBug` marks a case that fails because of an SRS bug, and the case fails once the bug is fixed, so the mark can't go stale. - **Clients and analyzers written for the suite:** RTMP and RTMPS (both handshakes, any chunk size, enhanced RTMP, malformed input), FLV, TS, AAC/MP3, MP4, HLS (TS, fMP4, AES-128, SAMPLE-AES) and DASH. Each one fills a shared stream report: codecs, picture size, sequence headers, keyframes, timestamps and A/V sync. - **Clients built on libraries:** WebRTC WHIP/WHEP on pion (UDP and TCP, NACK/RTX/TWCC recorder, raw DTLS sessions), SRT on gosrt, RTSP on gortsplib, and a GB28181 PS-over-RTP publisher with no SIP. - **Test media:** generated from `doc/source.flv` by `make integ-prepare` and never committed. The files cover H.264, HEVC, VP9, AV1, AAC, Opus, MP3 and G.711, in FLV, MP4 and TS. A manifest keeps the media from being made again when nothing changed. - **Feature coverage:** the HTTP API, HTTPS API and exporter, reload, heartbeat, RTMP, RTMPS, HTTP-FLV, HTTP-TS, HTTP AAC and MP3, HTTPS, HLS, DASH, WebRTC, SRT, RTSP, GB28181, the MPEG-TS over UDP caster, every HTTP hook, security rules, forward, edge, the origin cluster, DVR, static files and VOD, signals, the command line and env settings. - **Conversion matrix:** each source (RTMP/RTMPS, WHIP, SRT, GB28181) is played by each player (RTMP, HTTP-FLV, HTTP-TS, MP3, AAC, HLS ts and fMP4, DASH, WHEP, SRT, RTSP) with each codec it can carry. The README has the matrix. - **Scale:** `-integ.scale=N` runs each case N times. A run of about 1,000 cases passes in about 11s. ## SRS bug fixes Every fix has a utest and, where a client can see it, an srs-integ case that failed before the fix. The main ones by area: - **HLS:** fMP4: - The default init file is now listed by its dir. - `hls_entry_prefix` now applies to fMP4 URIs. - MP3 streams now get an audio track in the init file. - Each publish now starts with a discontinuity. - `hls_keys` now encrypts the samples with cbcs. - A segment that fails to open is now dropped. Before, it crashed SRS. Other HLS fixes: key URIs, AES padding, HLS starts again after `hls_dispose`, the ratios, a TS republish, session names, and the `hls_ctx` of another stream's session is now checked as a new session. - **DASH:** the config, the MPD (window, update period, HEVC and MP3 codecs), init files of MP3 and of one-track streams, and dispose. - **WebRTC:** - Keyframes with an SEI, and Sender Reports and their mapping. - Opus timing, G.711, and WHEP SSRCs. - A refused WHIP publisher now frees its stream. - The start and hooks, and WebRTC over TCP. - **SRT:** only the configured latencies are set, as ffmpeg does. Also fixed: HEVC headers and CTS, frame counts, and the listener. - **RTSP:** a republish to a kept player, PLAY without SETUP, unknown methods, closes, DESCRIBE/TEARDOWN, request bodies and `on_stop`. - **GB28181:** - SSRC parsing. - HEVC to RTMP is no longer sent in legacy FLV tags. - A crash on a second media connection. - Latency with no audio, sliced frames, CTS, session lifetime, and the muxer and caster output. - **RTMP and HTTP:** - Timestamps that jump back without time correction no longer stop a player. - The republish param and the publish timeout after a pause. - The no-origin close and the RTMPS port. - HTTP ranges, including MP4 suffix ranges and VOD 416. - Redirects, mounts, unmounts and URL `..`. - CORS, API auth paths, JSONP 400 and 403 answers. - Static files and the request echo. - **Hooks:** - The heartbeat URL's query is now sent. - `on_play` clients and client types, and `on_dvr`. - A client that `on_connect` refused no longer gets `on_close`. - **Other:** DVR (MP4 info frame, audio rate, audio cut), listen ports above 65535, the env vhost check, the UDP caster, process stats, the tcmalloc rate, the encoder loop, the edge failover, STUN, coroutine joins, and the resource manager's fast-id cache (WebRTC and GB lookups never used it). ## Behavior changes - **Hooks:** - `on_publish`, `on_unpublish`, `on_play` and `on_stop` have a new `type` field after `client_id`, such as `rtmp-play` or `hls-play`, the same value as `/api/v1/clients`. - Every protocol now sends `param` with its leading `?`. - HTTP players send their own `http(s)://` tcUrl, and WHIP/WHEP clients send `webrtc://<host>/<app>`. - **API:** `/api/v1/features` adds `srt`, `rtsp`, `gb28181`, `rtc`, `simulator` and `sanitizer`. The `/api/v1/` index lists only the URLs SRS serves. - **Env support:** - The vhost security rules. - The edge protocol. - The origin cluster and coworkers. - The DVR apply filter. - **Timers in seconds:** `hls_dispose`, `dash_dispose`, `dvr_duration`, `queue_length`, `stun_timeout` and the heartbeat `interval` now accept fractions such as `0.5`. A fraction used to be cut to whole seconds, so `0.5` read as 0. - **Removed features:** a config or env var that names one of these, on or off, now makes SRS refuse to start (and `-t` fail) with `<name> is removed`: - HDS, including `--hds=on`. - The FLV POST caster and `asprocess`. - `query_latest_version` and `first_wait_for_qlv`. - The vhost `bandcheck`, `refer` and `atc_auto`. - Edge `token_traverse`. Check clients with the edge's HTTP hooks instead. ## Tests, CI and skills - **CI:** a new `integration-test` job builds `trunk/Dockerfile.test` (now with `--simulator=on`) and runs srs-integ. - It caches the test media. - It runs the main cases and the timer cases as separate steps, each retried on its own. - Limits are scaled by 4 with `-integ.limit-scale=4`. - **Unit tests:** more stable. They use random seeds, the test servers no longer share ports or leak fds, and a gtest listener fails a test that leaves a handler on a global resource manager. - **Skills:** - The `srs-develop` Gate and Full tiers run `make integ`. - `integration-tests.md` explains how to add cases. - The new `internal-audit-for-srs` skill audits each commit blind before review. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
522 lines
20 KiB
Go
522 lines
20 KiB
Go
// Copyright (c) 2026 Winlin
|
|
//
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cases
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"srs-integ/internal/check"
|
|
"srs-integ/internal/server"
|
|
)
|
|
|
|
// staticMimeTypes are the extensions SRS knows and the Content-Type it
|
|
// answers each with; any other is application/octet-stream. FLV is not one
|
|
// of them: on Common every .flv path is an HTTP-FLV stream of the remux.
|
|
var staticMimeTypes = []struct{ ext, mime string }{
|
|
{"html", "text/html"},
|
|
{"js", "text/javascript"},
|
|
{"css", "text/css"},
|
|
{"json", "application/json"},
|
|
{"xml", "text/xml"},
|
|
{"ico", "image/x-icon"},
|
|
{"png", "image/png"},
|
|
{"jpg", "image/jpeg"},
|
|
{"jpeg", "image/jpeg"},
|
|
{"gif", "image/gif"},
|
|
{"mp3", "audio/mpeg"},
|
|
{"aac", "audio/x-aac"},
|
|
{"m4a", "audio/x-m4a"},
|
|
{"ogg", "audio/ogg"},
|
|
{"zip", "application/zip"},
|
|
{"mpd", "application/dash+xml"},
|
|
{"ts", "video/MP2T"},
|
|
{"m4s", "video/iso.segment"},
|
|
{"mp4", "video/mp4"},
|
|
}
|
|
|
|
// TestHttpStatic_Common serves files on the Common server: the HTTP server's
|
|
// own dir, ./objs/nginx/html, at every path no vhost mounts, and the default
|
|
// vhost's files (http_static, server.CommonStatic) at /static/. Each case
|
|
// writes its files in a dir of its own, named like a stream, and asks for
|
|
// them; the paths it must not get are files outside both dirs. A case of one
|
|
// request is Fast; one of several requests, or of 1 MiB, is Normal, since a
|
|
// busy Common shard answers each request in up to 50ms.
|
|
func TestHttpStatic_Common(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// ServesFile gets a file: 200, its bytes, and its Content-Length.
|
|
check.Case(t, "ServesFile", check.Fast, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
data := []byte("file of " + dir)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.dat"), data)
|
|
|
|
res, body := staticGet(c, srs, "/"+dir+"/a.dat", nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.Check(bytes.Equal(body, data), "body %q, want %q", body, data)
|
|
c.CheckEqual(res.ContentLength, int64(len(data)), "Content-Length")
|
|
})
|
|
|
|
// LargeFile gets a file of 1 MiB of random bytes, all of them.
|
|
check.Case(t, "LargeFile", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
data := make([]byte, 1<<20)
|
|
rand.Read(data)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "large.bin"), data)
|
|
|
|
res, body := staticGet(c, srs, "/"+dir+"/large.bin", nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(res.ContentLength, int64(len(data)), "Content-Length")
|
|
c.Check(bytes.Equal(body, data), "body of %d bytes differs from the file's %d", len(body), len(data))
|
|
})
|
|
|
|
// Mime<Ext> gets a file of each extension SRS knows: its Content-Type.
|
|
for _, m := range staticMimeTypes {
|
|
check.Case(t, "Mime"+strings.ToUpper(m.ext[:1])+m.ext[1:], check.Fast, func(c *check.C) {
|
|
checkStaticType(c, server.Common(c), "f."+m.ext, m.mime)
|
|
})
|
|
}
|
|
|
|
// MimeUnknown gets a file of an extension SRS doesn't know, and one with
|
|
// none: application/octet-stream.
|
|
check.Case(t, "MimeUnknown", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
checkStaticType(c, srs, "f.txt", "application/octet-stream")
|
|
checkStaticType(c, srs, "README", "application/octet-stream")
|
|
})
|
|
|
|
// QueryIgnored gets a file with a query: the file.
|
|
check.Case(t, "QueryIgnored", check.Fast, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.json"), []byte(`{}`))
|
|
|
|
res, body := staticGet(c, srs, "/"+dir+"/a.json?v=1&k=x", nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(string(body), `{}`, "body")
|
|
})
|
|
|
|
// IndexOfDir asks for a dir with its slash: its index.html, as HTML.
|
|
check.Case(t, "IndexOfDir", check.Fast, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
page := "<p>" + dir + "</p>"
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "index.html"), []byte(page))
|
|
|
|
res, body := staticGet(c, srs, "/"+dir+"/", nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(res.Header.Get("Content-Type"), "text/html", "Content-Type")
|
|
c.CheckEqual(string(body), page, "body")
|
|
})
|
|
|
|
// DirWithoutIndex asks for a dir with no index.html: 404, not a listing.
|
|
check.Case(t, "DirWithoutIndex", check.Fast, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.dat"), []byte("a"))
|
|
|
|
res, body := staticGet(c, srs, "/"+dir+"/", nil)
|
|
c.CheckEqual(res.StatusCode, 404, "status")
|
|
c.Check(!bytes.Contains(body, []byte("a.dat")), "the 404 lists the dir: %q", body)
|
|
})
|
|
|
|
// NotFound asks for a missing file, and a file in a missing dir: 404.
|
|
check.Case(t, "NotFound", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.dat"), []byte("a"))
|
|
|
|
for _, p := range []string{"/" + dir + "/b.dat", "/" + dir + "-none/a.dat"} {
|
|
res, body := staticGet(c, srs, p, nil)
|
|
c.CheckEqual(res.StatusCode, 404, p+" status")
|
|
c.CheckEqual(string(body), "Not Found", p+" body")
|
|
}
|
|
})
|
|
|
|
// DirWithoutSlash asks for a dir without its slash, also as "/dir/.": an
|
|
// HTTP answer, a redirect to the dir or 404. A client that follows the
|
|
// redirect gets the dir's index.html.
|
|
check.Case(t, "DirWithoutSlash", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
page := "<p>" + dir + "</p>"
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "index.html"), []byte(page))
|
|
|
|
redirected := false
|
|
for _, p := range []string{"/" + dir, "/" + dir + "/."} {
|
|
status, _, err := rawStaticGet(c, srs.Addr(server.HTTPServer), p)
|
|
if c.CheckNoError(err, p+" answer") {
|
|
c.Check(status == 301 || status == 302 || status == 404, "%s status %d, want a redirect to the dir or 404", p, status)
|
|
redirected = redirected || status == 301 || status == 302
|
|
}
|
|
}
|
|
if redirected {
|
|
res, body := staticGet(c, srs, "/"+dir, nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status after the redirect")
|
|
c.CheckEqual(string(body), page, "body after the redirect")
|
|
}
|
|
checkStaticServes(c, srs)
|
|
})
|
|
|
|
// DirRedirectStaysOnTheHost asks for a dir without its slash with two
|
|
// leading slashes, or a backslash after the first: a redirect to a path
|
|
// on this host, never to "//dir/" or "/\dir/", which a browser reads as
|
|
// the host dir.
|
|
check.Case(t, "DirRedirectStaysOnTheHost", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "index.html"), []byte("<p>"+dir+"</p>"))
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), `\`+dir, "index.html"), []byte("<p>"+dir+"</p>"))
|
|
|
|
res, _, err := rawStaticResponse(c, srs.Addr(server.HTTPServer), "//"+dir)
|
|
if c.CheckNoError(err, "the answer") {
|
|
c.CheckEqual(res.StatusCode, 301, "status")
|
|
c.CheckEqual(res.Header.Get("Location"), "/"+dir+"/", "Location")
|
|
}
|
|
|
|
res, _, err = rawStaticResponse(c, srs.Addr(server.HTTPServer), `/\`+dir)
|
|
if c.CheckNoError(err, "the answer of the backslash") && res.StatusCode/100 == 3 {
|
|
location := res.Header.Get("Location")
|
|
c.Check(len(location) > 1 && location[0] == '/' && location[1] != '/' && location[1] != '\\',
|
|
"the backslash: Location %q, want a path on this host", location)
|
|
}
|
|
checkStaticServes(c, srs)
|
|
})
|
|
|
|
// TraversalNotServed asks for a file outside the dir through "..": SRS
|
|
// doesn't serve it, and serves the next request.
|
|
check.Case(t, "TraversalNotServed", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
secret := writeSecret(c, srs)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.dat"), []byte("a"))
|
|
|
|
for _, p := range []string{"/../../../" + secret, "/" + dir + "/../../../../" + secret, "/" + dir + "/./../../../../" + secret} {
|
|
checkNotServed(c, srs, p, secret)
|
|
}
|
|
checkStaticServes(c, srs)
|
|
})
|
|
|
|
// TraversalAnswered asks for a path with "..": a 400 with the error
|
|
// code and its name, which tells the client why, and SRS serves the
|
|
// next request.
|
|
check.Case(t, "TraversalAnswered", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
status, body, err := rawStaticGet(c, srs.Addr(server.HTTPServer), "/a/../"+streamName(c)+".dat")
|
|
if c.CheckNoError(err, "the answer") {
|
|
c.CheckEqual(status, 400, "status")
|
|
c.CheckEqual(string(body), "4002: HttpUrlNotClean", "body")
|
|
}
|
|
checkStaticServes(c, srs)
|
|
})
|
|
|
|
// TwoDotsServed gets a file whose name has two dots in it, and a file
|
|
// with two dots in the query: no ".." segment, so each file.
|
|
check.Case(t, "TwoDotsServed", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a..b.dat"), []byte("two dots"))
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.dat"), []byte("a"))
|
|
for _, f := range []struct{ target, body string }{
|
|
{"/" + dir + "/a..b.dat", "two dots"},
|
|
{"/" + dir + "/a.dat?t=a..b", "a"},
|
|
} {
|
|
status, body, err := rawStaticGet(c, srs.Addr(server.HTTPServer), f.target)
|
|
if c.CheckNoError(err, f.target+" answer") {
|
|
c.CheckEqual(status, 200, f.target+" status")
|
|
c.CheckEqual(string(body), f.body, f.target+" body")
|
|
}
|
|
}
|
|
})
|
|
|
|
// EncodedTraversalNotServed asks for a file outside the dir through ".."
|
|
// with its dots or slashes percent-encoded: SRS doesn't serve it.
|
|
check.Case(t, "EncodedTraversalNotServed", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
secret := writeSecret(c, srs)
|
|
for _, p := range []string{
|
|
"/%2e%2e/%2e%2e/%2e%2e/" + secret,
|
|
"/%2E%2E/%2E%2E/%2E%2E/" + secret,
|
|
"/..%2f..%2f..%2f" + secret,
|
|
"/%2e%2e%2f%2e%2e%2f%2e%2e%2f" + secret,
|
|
} {
|
|
checkNotServed(c, srs, p, secret)
|
|
}
|
|
checkStaticServes(c, srs)
|
|
})
|
|
|
|
// EscapedFileName gets files whose names need escaping, a space, a
|
|
// percent sign and a non-ASCII name, percent-encoded in the URL as they
|
|
// must be, and a plain name with its dot encoded: each file.
|
|
check.Case(t, "EscapedFileName", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
for _, f := range []struct{ name, target string }{
|
|
{"a b.dat", "a%20b.dat"},
|
|
{"100%.dat", "100%25.dat"},
|
|
{"名.dat", "%E5%90%8D.dat"},
|
|
{"a.txt", "a%2etxt"},
|
|
} {
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, f.name), []byte("data of "+f.name))
|
|
status, body, err := rawStaticGet(c, srs.Addr(server.HTTPServer), "/"+dir+"/"+f.target)
|
|
if c.CheckNoError(err, f.target+" answer") {
|
|
c.CheckEqual(status, 200, f.target+" status")
|
|
c.CheckEqual(string(body), "data of "+f.name, f.target+" body")
|
|
}
|
|
}
|
|
})
|
|
|
|
// CorsHeaders gets a file with an Origin header: the CORS headers, as
|
|
// crossdomain is on by default; none without one.
|
|
check.Case(t, "CorsHeaders", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.js"), []byte("x"))
|
|
|
|
res, _ := staticGet(c, srs, "/"+dir+"/a.js", originHeader)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(res.Header.Get("Access-Control-Allow-Origin"), "*", "Access-Control-Allow-Origin")
|
|
c.CheckEqual(res.Header.Get("Access-Control-Allow-Methods"), "*", "Access-Control-Allow-Methods")
|
|
|
|
res, _ = staticGet(c, srs, "/"+dir+"/a.js", nil)
|
|
c.CheckEqual(res.Header.Get("Access-Control-Allow-Origin"), "", "Access-Control-Allow-Origin without an Origin")
|
|
})
|
|
|
|
// CorsPreflight sends the OPTIONS preflight for a file: 200, the CORS
|
|
// headers and no body.
|
|
check.Case(t, "CorsPreflight", check.Fast, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.js"), []byte("x"))
|
|
|
|
res, body := staticDo(c, srs, http.MethodOptions, "/"+dir+"/a.js", originHeader)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(res.Header.Get("Access-Control-Allow-Origin"), "*", "Access-Control-Allow-Origin")
|
|
c.CheckEqual(len(body), 0, "body bytes")
|
|
})
|
|
|
|
// HttpsServesFile gets a file from the HTTPS server, which serves the
|
|
// same dirs.
|
|
check.Case(t, "HttpsServesFile", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.css"), []byte("p{}"))
|
|
writeStatic(c, filepath.Join(server.CommonStatic.DirIn(srs), dir, "b.css"), []byte("b{}"))
|
|
|
|
cfg, err := server.ClientTLS()
|
|
c.Must(err, "the client TLS config")
|
|
tr := httpClient.Transport.(*http.Transport).Clone()
|
|
tr.TLSClientConfig = cfg
|
|
client := &http.Client{Transport: tr, Timeout: time.Second}
|
|
c.Cleanup(tr.CloseIdleConnections)
|
|
for path, want := range map[string]string{
|
|
"/" + dir + "/a.css": "p{}",
|
|
server.CommonStatic.URLPath() + dir + "/b.css": "b{}",
|
|
} {
|
|
res, err := client.Get("https://" + srs.Addr(server.HTTPSServer) + path)
|
|
c.Must(err, "GET "+path)
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
c.CheckNoError(err, "read "+path)
|
|
c.CheckEqual(res.StatusCode, 200, path+" status")
|
|
c.CheckEqual(res.Header.Get("Content-Type"), "text/css", path+" Content-Type")
|
|
c.CheckEqual(string(body), want, path+" body")
|
|
}
|
|
})
|
|
|
|
// VhostMount gets a file of the vhost's dir, ./static/[vhost] with the
|
|
// default vhost's name, at its mount, [vhost]/static/ served at /static/.
|
|
check.Case(t, "VhostMount", check.Fast, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(server.CommonStatic.DirIn(srs), dir, "v.css"), []byte(dir))
|
|
|
|
res, body := staticGet(c, srs, server.CommonStatic.URLPath()+dir+"/v.css", nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(res.Header.Get("Content-Type"), "text/css", "Content-Type")
|
|
c.CheckEqual(string(body), dir, "body")
|
|
})
|
|
|
|
// VhostMountShadowsRoot puts files at the same path under the HTTP
|
|
// server's dir: the mount serves only its own dir, so a file there is
|
|
// served and one only in the server's dir is 404.
|
|
check.Case(t, "VhostMountShadowsRoot", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
rootDir := filepath.Join(srs.WebRoot(), strings.Trim(server.CommonStatic.URLPath(), "/"), dir)
|
|
writeStatic(c, filepath.Join(rootDir, "both.dat"), []byte("root"))
|
|
writeStatic(c, filepath.Join(rootDir, "root-only.dat"), []byte("root"))
|
|
writeStatic(c, filepath.Join(server.CommonStatic.DirIn(srs), dir, "both.dat"), []byte("vhost"))
|
|
|
|
res, body := staticGet(c, srs, server.CommonStatic.URLPath()+dir+"/both.dat", nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(string(body), "vhost", "body")
|
|
res, _ = staticGet(c, srs, server.CommonStatic.URLPath()+dir+"/root-only.dat", nil)
|
|
c.CheckEqual(res.StatusCode, 404, "status of a file only in the server's dir")
|
|
})
|
|
|
|
// VhostMountIndex asks for a dir of the mount with its slash: its
|
|
// index.html.
|
|
check.Case(t, "VhostMountIndex", check.Fast, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(server.CommonStatic.DirIn(srs), dir, "index.html"), []byte("<p>vhost</p>"))
|
|
|
|
res, body := staticGet(c, srs, server.CommonStatic.URLPath()+dir+"/", nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(string(body), "<p>vhost</p>", "body")
|
|
})
|
|
|
|
// VhostMountTraversalNotServed asks for a file outside the vhost's dir
|
|
// through "..", plain and percent-encoded, under the mount: SRS doesn't
|
|
// serve it.
|
|
check.Case(t, "VhostMountTraversalNotServed", check.Normal, func(c *check.C) {
|
|
srs := server.Common(c)
|
|
secret := writeSecret(c, srs)
|
|
mount := server.CommonStatic.URLPath()
|
|
for _, p := range []string{mount + "../../" + secret, mount + "%2e%2e/%2e%2e/" + secret} {
|
|
checkNotServed(c, srs, p, secret)
|
|
}
|
|
checkStaticServes(c, srs)
|
|
})
|
|
|
|
vodMp4Cases(t)
|
|
vodM3u8Cases(t)
|
|
}
|
|
|
|
// staticCrossdomainOffCases adds the static file cases of a server with the
|
|
// HTTP server's crossdomain off to its group.
|
|
func staticCrossdomainOffCases(t *testing.T, srs *server.Server) {
|
|
// StaticNoCorsHeaders gets a file with an Origin header: no CORS
|
|
// headers; the preflight is 405.
|
|
check.Case(t, "StaticNoCorsHeaders", check.Normal, func(c *check.C) {
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "a.js"), []byte("x"))
|
|
|
|
res, body := staticGet(c, srs, "/"+dir+"/a.js", originHeader)
|
|
c.CheckEqual(res.StatusCode, 200, "status")
|
|
c.CheckEqual(string(body), "x", "body")
|
|
c.CheckEqual(res.Header.Get("Access-Control-Allow-Origin"), "", "Access-Control-Allow-Origin")
|
|
res, _ = staticDo(c, srs, http.MethodOptions, "/"+dir+"/a.js", originHeader)
|
|
c.CheckEqual(res.StatusCode, 405, "preflight status")
|
|
})
|
|
}
|
|
|
|
// writeStatic writes a file for the server to serve, with its dirs, and
|
|
// removes it when the case ends.
|
|
func writeStatic(c *check.C, file string, data []byte) {
|
|
c.Helper()
|
|
c.Must(os.MkdirAll(filepath.Dir(file), 0o755), "make the file's dir")
|
|
c.Must(os.WriteFile(file, data, 0o644), "write the file")
|
|
c.Cleanup(func() { os.Remove(file) })
|
|
}
|
|
|
|
// writeSecret writes a file in the server's directory, outside every dir it
|
|
// serves, three levels above the HTTP server's dir, and returns its name.
|
|
func writeSecret(c *check.C, srs *server.Server) string {
|
|
c.Helper()
|
|
name := streamName(c) + ".secret"
|
|
writeStatic(c, filepath.Join(srs.Dir, name), []byte("secret of "+name))
|
|
return name
|
|
}
|
|
|
|
// staticGet gets a path of the server's HTTP server, with the header, and
|
|
// returns the answer and its body.
|
|
func staticGet(c *check.C, srs *server.Server, path string, header http.Header) (*http.Response, []byte) {
|
|
c.Helper()
|
|
return staticDo(c, srs, http.MethodGet, path, header)
|
|
}
|
|
|
|
// staticDo sends a request for a path to the server's HTTP server.
|
|
func staticDo(c *check.C, srs *server.Server, method, path string, header http.Header) (*http.Response, []byte) {
|
|
c.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, method, "http://"+srs.Addr(server.HTTPServer)+path, nil)
|
|
c.Must(err, "the request")
|
|
for k, v := range header {
|
|
req.Header[k] = v
|
|
}
|
|
res, err := httpClient.Do(req)
|
|
c.Must(err, method+" "+path)
|
|
defer res.Body.Close()
|
|
body, err := io.ReadAll(res.Body)
|
|
c.CheckNoError(err, "read "+path)
|
|
return res, body
|
|
}
|
|
|
|
// rawStaticGet sends a GET of target as it is, which a client library might
|
|
// clean, and reads the answer for up to 1s: its status and body, or an error
|
|
// when the server closes the connection without one.
|
|
func rawStaticGet(c *check.C, addr, target string) (int, []byte, error) {
|
|
c.Helper()
|
|
res, body, err := rawStaticResponse(c, addr, target)
|
|
if res == nil {
|
|
return 0, nil, err
|
|
}
|
|
return res.StatusCode, body, err
|
|
}
|
|
|
|
// rawStaticResponse is rawStaticGet with the response's headers.
|
|
func rawStaticResponse(c *check.C, addr, target string) (*http.Response, []byte, error) {
|
|
c.Helper()
|
|
conn := rawConnTo(c, addr)
|
|
conn.SetDeadline(time.Now().Add(time.Second))
|
|
if _, err := fmt.Fprintf(conn, "GET %s HTTP/1.1\r\nHost: localhost\r\n\r\n", target); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
body, err := io.ReadAll(res.Body)
|
|
return res, body, err
|
|
}
|
|
|
|
// checkStaticType gets a file of the name and checks its Content-Type.
|
|
func checkStaticType(c *check.C, srs *server.Server, name, mime string) {
|
|
c.Helper()
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, name), []byte("data of "+name))
|
|
|
|
res, body := staticGet(c, srs, "/"+dir+"/"+name, nil)
|
|
c.CheckEqual(res.StatusCode, 200, name+" status")
|
|
c.CheckEqual(res.Header.Get("Content-Type"), mime, name+" Content-Type")
|
|
c.CheckEqual(string(body), "data of "+name, name+" body")
|
|
}
|
|
|
|
// checkNotServed sends a GET of target as it is and checks the answer is not
|
|
// the secret file: any answer but 200, or the connection closed.
|
|
func checkNotServed(c *check.C, srs *server.Server, target, secret string) {
|
|
c.Helper()
|
|
status, body, err := rawStaticGet(c, srs.Addr(server.HTTPServer), target)
|
|
c.Check(!isTimeout(err), "%s: no answer nor close in 1s", target)
|
|
c.Check(err != nil || status != 200, "%s: 200, want it refused", target)
|
|
c.Check(!bytes.Contains(body, []byte("secret of "+secret)), "%s: served the file outside the dir", target)
|
|
}
|
|
|
|
// checkStaticServes checks the server still serves a file.
|
|
func checkStaticServes(c *check.C, srs *server.Server) {
|
|
c.Helper()
|
|
dir := streamName(c)
|
|
writeStatic(c, filepath.Join(srs.WebRoot(), dir, "after.dat"), []byte("after"))
|
|
res, body := staticGet(c, srs, "/"+dir+"/after.dat", nil)
|
|
c.CheckEqual(res.StatusCode, 200, "status of a file after")
|
|
c.CheckEqual(string(body), "after", "body of a file after")
|
|
}
|