package integration import ( "crypto/md5" "crypto/rand" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "time" "github.com/go-check/check" "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/integration/try" "github.com/traefik/traefik/v3/pkg/middlewares/accesslog" checker "github.com/vdemeester/shakers" ) const ( traefikTestLogFile = "traefik.log" traefikTestAccessLogFile = "access.log" ) // AccessLogSuite tests suite. type AccessLogSuite struct{ BaseSuite } type accessLogValue struct { formatOnly bool code string user string routerName string serviceURL string } func (s *AccessLogSuite) SetUpSuite(c *check.C) { s.createComposeProject(c, "access_log") s.composeUp(c) } func (s *AccessLogSuite) TearDownTest(c *check.C) { displayTraefikLogFile(c, traefikTestLogFile) _ = os.Remove(traefikTestAccessLogFile) } func (s *AccessLogSuite) TestAccessLog(c *check.C) { ensureWorkingDirectoryIsClean() // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) defer func() { traefikLog, err := os.ReadFile(traefikTestLogFile) c.Assert(err, checker.IsNil) log.Info().Msg(string(traefikLog)) }() err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) waitForTraefik(c, "server1") checkStatsForLogFile(c) // Verify Traefik started OK checkTraefikStarted(c) // Make some requests req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil) c.Assert(err, checker.IsNil) req.Host = "frontend1.docker.local" err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil) c.Assert(err, checker.IsNil) req.Host = "frontend2.docker.local" err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogOutput(c) c.Assert(count, checker.GreaterOrEqualThan, 3) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } func (s *AccessLogSuite) TestAccessLogAuthFrontend(c *check.C) { ensureWorkingDirectoryIsClean() expected := []accessLogValue{ { formatOnly: false, code: "401", user: "-", routerName: "rt-authFrontend", serviceURL: "-", }, { formatOnly: false, code: "401", user: "test", routerName: "rt-authFrontend", serviceURL: "-", }, { formatOnly: false, code: "200", user: "test", routerName: "rt-authFrontend", serviceURL: "http://172.31.42", }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) checkStatsForLogFile(c) waitForTraefik(c, "authFrontend") // Verify Traefik started OK checkTraefikStarted(c) // Test auth entrypoint req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8006/", nil) c.Assert(err, checker.IsNil) req.Host = "frontend.auth.docker.local" err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusUnauthorized), try.HasBody()) c.Assert(err, checker.IsNil) req.SetBasicAuth("test", "") err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusUnauthorized), try.HasBody()) c.Assert(err, checker.IsNil) req.SetBasicAuth("test", "test") err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogExactValuesOutput(c, expected) c.Assert(count, checker.GreaterOrEqualThan, len(expected)) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } func (s *AccessLogSuite) TestAccessLogDigestAuthMiddleware(c *check.C) { ensureWorkingDirectoryIsClean() expected := []accessLogValue{ { formatOnly: false, code: "401", user: "-", routerName: "rt-digestAuthMiddleware", serviceURL: "-", }, { formatOnly: false, code: "401", user: "test", routerName: "rt-digestAuthMiddleware", serviceURL: "-", }, { formatOnly: false, code: "200", user: "test", routerName: "rt-digestAuthMiddleware", serviceURL: "http://172.31.42", }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) checkStatsForLogFile(c) waitForTraefik(c, "digestAuthMiddleware") // Verify Traefik started OK checkTraefikStarted(c) // Test auth entrypoint req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8008/", nil) c.Assert(err, checker.IsNil) req.Host = "entrypoint.digest.auth.docker.local" resp, err := try.ResponseUntilStatusCode(req, 500*time.Millisecond, http.StatusUnauthorized) c.Assert(err, checker.IsNil) digest := digestParts(resp) digest["uri"] = "/" digest["method"] = http.MethodGet digest["username"] = "test" digest["password"] = "wrong" req.Header.Set("Authorization", getDigestAuthorization(digest)) req.Header.Set("Content-Type", "application/json") err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusUnauthorized), try.HasBody()) c.Assert(err, checker.IsNil) digest["password"] = "test" req.Header.Set("Authorization", getDigestAuthorization(digest)) err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogExactValuesOutput(c, expected) c.Assert(count, checker.GreaterOrEqualThan, len(expected)) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } // Thanks to mvndaai for digest authentication // https://stackoverflow.com/questions/39474284/how-do-you-do-a-http-post-with-digest-authentication-in-golang/39481441#39481441 func digestParts(resp *http.Response) map[string]string { result := map[string]string{} if len(resp.Header["Www-Authenticate"]) > 0 { wantedHeaders := []string{"nonce", "realm", "qop", "opaque"} responseHeaders := strings.Split(resp.Header["Www-Authenticate"][0], ",") for _, r := range responseHeaders { for _, w := range wantedHeaders { if strings.Contains(r, w) { result[w] = strings.Split(r, `"`)[1] } } } } return result } func getMD5(data string) string { digest := md5.New() if _, err := digest.Write([]byte(data)); err != nil { log.Error().Err(err).Send() } return fmt.Sprintf("%x", digest.Sum(nil)) } func getCnonce() string { b := make([]byte, 8) if _, err := io.ReadFull(rand.Reader, b); err != nil { log.Error().Err(err).Send() } return fmt.Sprintf("%x", b)[:16] } func getDigestAuthorization(digestParts map[string]string) string { d := digestParts ha1 := getMD5(d["username"] + ":" + d["realm"] + ":" + d["password"]) ha2 := getMD5(d["method"] + ":" + d["uri"]) nonceCount := "00000001" cnonce := getCnonce() response := getMD5(fmt.Sprintf("%s:%s:%s:%s:%s:%s", ha1, d["nonce"], nonceCount, cnonce, d["qop"], ha2)) authorization := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", cnonce="%s", nc=%s, qop=%s, response="%s", opaque="%s", algorithm="MD5"`, d["username"], d["realm"], d["nonce"], d["uri"], cnonce, nonceCount, d["qop"], response, d["opaque"]) return authorization } func (s *AccessLogSuite) TestAccessLogFrontendRedirect(c *check.C) { ensureWorkingDirectoryIsClean() expected := []accessLogValue{ { formatOnly: false, code: "302", user: "-", routerName: "rt-frontendRedirect", serviceURL: "-", }, { formatOnly: true, }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) checkStatsForLogFile(c) waitForTraefik(c, "frontendRedirect") // Verify Traefik started OK checkTraefikStarted(c) // Test frontend redirect req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8005/test", nil) c.Assert(err, checker.IsNil) req.Host = "" err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogExactValuesOutput(c, expected) c.Assert(count, checker.GreaterOrEqualThan, len(expected)) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } func (s *AccessLogSuite) TestAccessLogJSONFrontendRedirect(c *check.C) { ensureWorkingDirectoryIsClean() type logLine struct { DownstreamStatus int `json:"downstreamStatus"` OriginStatus int `json:"originStatus"` RouterName string `json:"routerName"` ServiceName string `json:"serviceName"` } expected := []logLine{ { DownstreamStatus: 302, OriginStatus: 0, RouterName: "rt-frontendRedirect@docker", ServiceName: "", }, { DownstreamStatus: 200, OriginStatus: 200, RouterName: "rt-server0@docker", ServiceName: "service1@docker", }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_json_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) checkStatsForLogFile(c) waitForTraefik(c, "frontendRedirect") // Verify Traefik started OK checkTraefikStarted(c) // Test frontend redirect req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8005/test", nil) c.Assert(err, checker.IsNil) req.Host = "" err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) lines := extractLines(c) c.Assert(len(lines), checker.GreaterOrEqualThan, len(expected)) for i, line := range lines { if line == "" { continue } var logline logLine err := json.Unmarshal([]byte(line), &logline) c.Assert(err, checker.IsNil) c.Assert(logline.DownstreamStatus, checker.Equals, expected[i].DownstreamStatus) c.Assert(logline.OriginStatus, checker.Equals, expected[i].OriginStatus) c.Assert(logline.RouterName, checker.Equals, expected[i].RouterName) c.Assert(logline.ServiceName, checker.Equals, expected[i].ServiceName) } } func (s *AccessLogSuite) TestAccessLogRateLimit(c *check.C) { ensureWorkingDirectoryIsClean() expected := []accessLogValue{ { formatOnly: true, }, { formatOnly: true, }, { formatOnly: false, code: "429", user: "-", routerName: "rt-rateLimit", serviceURL: "-", }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) checkStatsForLogFile(c) waitForTraefik(c, "rateLimit") // Verify Traefik started OK checkTraefikStarted(c) // Test rate limit req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8007/", nil) c.Assert(err, checker.IsNil) req.Host = "ratelimit.docker.local" err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusTooManyRequests), try.HasBody()) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogExactValuesOutput(c, expected) c.Assert(count, checker.GreaterOrEqualThan, len(expected)) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } func (s *AccessLogSuite) TestAccessLogBackendNotFound(c *check.C) { ensureWorkingDirectoryIsClean() expected := []accessLogValue{ { formatOnly: false, code: "404", user: "-", routerName: "-", serviceURL: "-", }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) waitForTraefik(c, "server1") checkStatsForLogFile(c) // Verify Traefik started OK checkTraefikStarted(c) // Test rate limit req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil) c.Assert(err, checker.IsNil) req.Host = "backendnotfound.docker.local" err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusNotFound), try.HasBody()) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogExactValuesOutput(c, expected) c.Assert(count, checker.GreaterOrEqualThan, len(expected)) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } func (s *AccessLogSuite) TestAccessLogFrontendAllowlist(c *check.C) { ensureWorkingDirectoryIsClean() expected := []accessLogValue{ { formatOnly: false, code: "403", user: "-", routerName: "rt-frontendAllowlist", serviceURL: "-", }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) checkStatsForLogFile(c) waitForTraefik(c, "frontendAllowlist") // Verify Traefik started OK checkTraefikStarted(c) // Test rate limit req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil) c.Assert(err, checker.IsNil) req.Host = "frontend.allowlist.docker.local" err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusForbidden), try.HasBody()) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogExactValuesOutput(c, expected) c.Assert(count, checker.GreaterOrEqualThan, len(expected)) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } func (s *AccessLogSuite) TestAccessLogAuthFrontendSuccess(c *check.C) { ensureWorkingDirectoryIsClean() expected := []accessLogValue{ { formatOnly: false, code: "200", user: "test", routerName: "rt-authFrontend", serviceURL: "http://172.31.42", }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) checkStatsForLogFile(c) waitForTraefik(c, "authFrontend") // Verify Traefik started OK checkTraefikStarted(c) // Test auth entrypoint req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8006/", nil) c.Assert(err, checker.IsNil) req.Host = "frontend.auth.docker.local" req.SetBasicAuth("test", "test") err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK), try.HasBody()) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogExactValuesOutput(c, expected) c.Assert(count, checker.GreaterOrEqualThan, len(expected)) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } func (s *AccessLogSuite) TestAccessLogPreflightHeadersMiddleware(c *check.C) { ensureWorkingDirectoryIsClean() expected := []accessLogValue{ { formatOnly: false, code: "200", user: "-", routerName: "rt-preflightCORS", serviceURL: "-", }, } // Start Traefik cmd, display := s.traefikCmd(withConfigFile("fixtures/access_log_config.toml")) defer display(c) err := cmd.Start() c.Assert(err, checker.IsNil) defer s.killCmd(cmd) checkStatsForLogFile(c) waitForTraefik(c, "preflightCORS") // Verify Traefik started OK checkTraefikStarted(c) // Test preflight response req, err := http.NewRequest(http.MethodOptions, "http://127.0.0.1:8009/", nil) c.Assert(err, checker.IsNil) req.Host = "preflight.docker.local" req.Header.Set("Origin", "whatever") req.Header.Set("Access-Control-Request-Method", "GET") err = try.Request(req, 500*time.Millisecond, try.StatusCodeIs(http.StatusOK)) c.Assert(err, checker.IsNil) // Verify access.log output as expected count := checkAccessLogExactValuesOutput(c, expected) c.Assert(count, checker.GreaterOrEqualThan, len(expected)) // Verify no other Traefik problems checkNoOtherTraefikProblems(c) } func checkNoOtherTraefikProblems(c *check.C) { traefikLog, err := os.ReadFile(traefikTestLogFile) c.Assert(err, checker.IsNil) if len(traefikLog) > 0 { fmt.Printf("%s\n", string(traefikLog)) } } func checkAccessLogOutput(c *check.C) int { lines := extractLines(c) count := 0 for i, line := range lines { if len(line) > 0 { count++ CheckAccessLogFormat(c, line, i) } } return count } func checkAccessLogExactValuesOutput(c *check.C, values []accessLogValue) int { lines := extractLines(c) count := 0 for i, line := range lines { fmt.Println(line) if len(line) > 0 { count++ if values[i].formatOnly { CheckAccessLogFormat(c, line, i) } else { checkAccessLogExactValues(c, line, i, values[i]) } } } return count } func extractLines(c *check.C) []string { accessLog, err := os.ReadFile(traefikTestAccessLogFile) c.Assert(err, checker.IsNil) lines := strings.Split(string(accessLog), "\n") var clean []string for _, line := range lines { if !strings.Contains(line, "/api/rawdata") { clean = append(clean, line) } } return clean } func checkStatsForLogFile(c *check.C) { err := try.Do(1*time.Second, func() error { if _, errStat := os.Stat(traefikTestLogFile); errStat != nil { return fmt.Errorf("could not get stats for log file: %w", errStat) } return nil }) c.Assert(err, checker.IsNil) } func ensureWorkingDirectoryIsClean() { os.Remove(traefikTestAccessLogFile) os.Remove(traefikTestLogFile) } func checkTraefikStarted(c *check.C) []byte { traefikLog, err := os.ReadFile(traefikTestLogFile) c.Assert(err, checker.IsNil) if len(traefikLog) > 0 { fmt.Printf("%s\n", string(traefikLog)) } return traefikLog } func CheckAccessLogFormat(c *check.C, line string, i int) { results, err := accesslog.ParseAccessLog(line) c.Assert(err, checker.IsNil) c.Assert(results, checker.HasLen, 14) c.Assert(results[accesslog.OriginStatus], checker.Matches, `^(-|\d{3})$`) count, _ := strconv.Atoi(results[accesslog.RequestCount]) c.Assert(count, checker.GreaterOrEqualThan, i+1) c.Assert(results[accesslog.RouterName], checker.Matches, `"(rt-.+@docker|api@internal)"`) c.Assert(results[accesslog.ServiceURL], checker.HasPrefix, `"http://`) c.Assert(results[accesslog.Duration], checker.Matches, `^\d+ms$`) } func checkAccessLogExactValues(c *check.C, line string, i int, v accessLogValue) { results, err := accesslog.ParseAccessLog(line) c.Assert(err, checker.IsNil) c.Assert(results, checker.HasLen, 14) if len(v.user) > 0 { c.Assert(results[accesslog.ClientUsername], checker.Equals, v.user) } c.Assert(results[accesslog.OriginStatus], checker.Equals, v.code) count, _ := strconv.Atoi(results[accesslog.RequestCount]) c.Assert(count, checker.GreaterOrEqualThan, i+1) c.Assert(results[accesslog.RouterName], checker.Matches, `^"?`+v.routerName+`.*(@docker)?$`) c.Assert(results[accesslog.ServiceURL], checker.Matches, `^"?`+v.serviceURL+`.*$`) c.Assert(results[accesslog.Duration], checker.Matches, `^\d+ms$`) } func waitForTraefik(c *check.C, containerName string) { time.Sleep(1 * time.Second) // Wait for Traefik to turn ready. req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080/api/rawdata", nil) c.Assert(err, checker.IsNil) err = try.Request(req, 2*time.Second, try.StatusCodeIs(http.StatusOK), try.BodyContains(containerName)) c.Assert(err, checker.IsNil) } func displayTraefikLogFile(c *check.C, path string) { if c.Failed() { if _, err := os.Stat(path); !os.IsNotExist(err) { content, errRead := os.ReadFile(path) fmt.Printf("%s: Traefik logs: \n", c.TestName()) if errRead == nil { fmt.Println(content) } else { fmt.Println(errRead) } } else { fmt.Printf("%s: No Traefik logs.\n", c.TestName()) } errRemove := os.Remove(path) if errRemove != nil { fmt.Println(errRemove) } } }