diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index c173d27..af882a3 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -30,12 +30,22 @@ func main() { sm := setup.NewManager(config.serverURL, ds, cm) server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody) + recorder := proxy.NewRecorder(config.dataDir) + patternsPath := filepath.Join(config.dataDir, "patterns.json") + patterns, err := proxy.LoadPatterns(patternsPath) + if err == nil && len(patterns) > 0 { + recorder.Patterns = patterns + } else if err != nil { + log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err) + } + server.SetRecorder(recorder) + tlsConfig, err := cm.GetServerTLSConfig(config.domains) if err != nil { log.Printf("Warning: Failed to setup TLS: %v", err) } - pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody) + pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody, recorder) startDeviceDiscovery(server) @@ -182,7 +192,7 @@ func initCertificateManager(dataDir string) *certmanager.CertificateManager { return cm } -func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseProxy { +func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Recorder) *httputil.ReverseProxy { target, err := url.Parse(targetURL) if err != nil { log.Fatalf("Failed to parse target URL: %v", err) @@ -197,6 +207,7 @@ func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseP currentLp := proxy.NewLoggingProxy(target.String(), redact) currentLp.LogBody = logBody + currentLp.SetRecorder(recorder) currentLp.LogResponse(res) return nil @@ -208,6 +219,7 @@ func setupPythonProxy(targetURL string, redact, logBody bool) *httputil.ReverseP currentLp := proxy.NewLoggingProxy(target.String(), redact) currentLp.LogBody = logBody + currentLp.SetRecorder(recorder) currentLp.LogRequest(req) } @@ -227,6 +239,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) + r.Use(server.RecordMiddleware) r.Get("/", server.HandleRoot) r.Get("/health", server.HandleHealth) diff --git a/data/.gitignore b/data/.gitignore index 670a6b0..e619f3e 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,2 +1,3 @@ certs/ default/ +interactions/ diff --git a/data/patterns.json b/data/patterns.json new file mode 100644 index 0000000..db6eeb1 --- /dev/null +++ b/data/patterns.json @@ -0,0 +1,17 @@ +[ + { + "name": "IPv4", + "regexp": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$", + "replacement": "{ip}" + }, + { + "name": "DeviceID", + "regexp": "^[A-F0-9]{12}$", + "replacement": "{deviceId}" + }, + { + "name": "AccountID", + "regexp": "^\\d{1,10}$", + "replacement": "{accountId}" + } +] diff --git a/pkg/service/handlers/handlers_proxy.go b/pkg/service/handlers/handlers_proxy.go index 9fad1f4..73d692a 100644 --- a/pkg/service/handlers/handlers_proxy.go +++ b/pkg/service/handlers/handlers_proxy.go @@ -35,6 +35,7 @@ func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) { lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact) lp.LogBody = s.proxyLogBody + lp.SetRecorder(s.recorder) proxy := httputil.NewSingleHostReverseProxy(target) // Update director to set the correct host and path diff --git a/pkg/service/handlers/recorder_middleware.go b/pkg/service/handlers/recorder_middleware.go new file mode 100644 index 0000000..2cc884d --- /dev/null +++ b/pkg/service/handlers/recorder_middleware.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "net/http" +) + +// RecordMiddleware returns a middleware that records "self" requests and responses. +func (s *Server) RecordMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.recorder == nil { + next.ServeHTTP(w, r) + return + } + + // Buffer the request body if it exists + var reqBody []byte + if r.Body != nil { + var err error + reqBody, err = io.ReadAll(r.Body) + if err == nil { + r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + } + } + + // wrap ResponseWriter to capture the response + rw := &responseWriter{ + ResponseWriter: w, + body: &bytes.Buffer{}, + } + + next.ServeHTTP(rw, r) + + // Create a response object for the recorder + res := rw.getRecordedResponse(r) + + // Put back the original request body for recording + r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + + _ = s.recorder.Record("self", r, res) + }) +} + +type responseWriter struct { + http.ResponseWriter + statusCode int + body *bytes.Buffer +} + +func (rw *responseWriter) Header() http.Header { + return rw.ResponseWriter.Header() +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + rw.body.Write(b) + return rw.ResponseWriter.Write(b) +} + +func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response { + statusCode := rw.statusCode + if statusCode == 0 { + statusCode = http.StatusOK + } + + return &http.Response{ + StatusCode: statusCode, + Header: rw.ResponseWriter.Header(), + Body: io.NopCloser(bytes.NewBuffer(rw.body.Bytes())), + Request: r, + } +} + +func (rw *responseWriter) Flush() { + if f, ok := rw.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (rw *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := rw.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, fmt.Errorf("ResponseWriter does not support Hijacker") +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 9f5f9f5..84a92a8 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -8,6 +8,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/discovery" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/proxy" "github.com/gesellix/bose-soundtouch/pkg/service/setup" ) @@ -20,6 +21,7 @@ type Server struct { discovering bool proxyRedact bool proxyLogBody bool + recorder *proxy.Recorder } // NewServer creates a new SoundTouch service server. @@ -34,6 +36,11 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro } } +// SetRecorder sets the recorder for the server. +func (s *Server) SetRecorder(r *proxy.Recorder) { + s.recorder = r +} + // DiscoverDevices starts a background device discovery process. // //nolint:contextcheck diff --git a/pkg/service/proxy/patterns.go b/pkg/service/proxy/patterns.go new file mode 100644 index 0000000..e949c02 --- /dev/null +++ b/pkg/service/proxy/patterns.go @@ -0,0 +1,67 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "os" + "regexp" +) + +// PathPattern defines a regex and its replacement for sanitizing URL paths. +type PathPattern struct { + Name string `json:"name"` + Regexp string `json:"regexp"` + Replacement string `json:"replacement"` + compiled *regexp.Regexp +} + +// PathPatterns is a collection of PathPattern. +type PathPatterns []PathPattern + +// LoadPatterns loads path patterns from a JSON file. +func LoadPatterns(path string) (PathPatterns, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return PathPatterns{}, nil + } + return nil, err + } + + var patterns PathPatterns + if err := json.Unmarshal(data, &patterns); err != nil { + return nil, err + } + + for i := range patterns { + re, err := regexp.Compile(patterns[i].Regexp) + if err != nil { + return nil, fmt.Errorf("invalid regex in pattern %s: %w", patterns[i].Name, err) + } + patterns[i].compiled = re + } + + return patterns, nil +} + +// Sanitize sanitizes a segment using the configured patterns. +func (pp PathPatterns) Sanitize(segment string) (string, string) { + for _, p := range pp { + if p.compiled != nil && p.compiled.MatchString(segment) { + return p.Replacement, p.Replacement + } + } + return segment, "" +} + +// DefaultPatterns returns the default set of path patterns. +func DefaultPatterns() PathPatterns { + p := PathPattern{ + Name: "IPv4", + Regexp: `^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`, + Replacement: "{ip}", + } + re, _ := regexp.Compile(p.Regexp) + p.compiled = re + return PathPatterns{p} +} diff --git a/pkg/service/proxy/proxy.go b/pkg/service/proxy/proxy.go index 381f65c..8a415e0 100644 --- a/pkg/service/proxy/proxy.go +++ b/pkg/service/proxy/proxy.go @@ -24,6 +24,7 @@ type LoggingProxy struct { Redact bool LogBody bool MaxBodySize int64 + Recorder *Recorder } // NewLoggingProxy creates a lightweight logger for HTTP requests/responses. @@ -36,6 +37,11 @@ func NewLoggingProxy(_ string, redact bool) *LoggingProxy { } } +// SetRecorder sets the recorder for the proxy. +func (lp *LoggingProxy) SetRecorder(r *Recorder) { + lp.Recorder = r +} + // LogRequest prints an abbreviated request with optional header/body redaction. func (lp *LoggingProxy) LogRequest(r *http.Request) { headers := formatHeaders(r.Header, lp.Redact) @@ -82,6 +88,10 @@ func (lp *LoggingProxy) LogResponse(r *http.Response) { } log.Printf("[PROXY_RES] %d %s\n Headers:\n%s\n Body: %s", r.StatusCode, r.Request.URL.String(), headers, bodyStr) + + if lp.Recorder != nil { + _ = lp.Recorder.Record("upstream", r.Request, r) + } } func formatHeaders(h http.Header, redact bool) string { diff --git a/pkg/service/proxy/recorder.go b/pkg/service/proxy/recorder.go new file mode 100644 index 0000000..44caf9e --- /dev/null +++ b/pkg/service/proxy/recorder.go @@ -0,0 +1,177 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Recorder handles persisting HTTP interactions as .http files. +type Recorder struct { + BaseDir string + SessionID string + SessionDir string + Patterns PathPatterns + counter uint64 + variables map[string]string + mu sync.Mutex +} + +// NewRecorder creates a new HTTP interaction recorder. +func NewRecorder(baseDir string) *Recorder { + sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid()) + return &Recorder{ + BaseDir: baseDir, + SessionID: sessionID, + Patterns: DefaultPatterns(), + variables: make(map[string]string), + } +} + +// Record persists a request and response to a .http file in the specified category (e.g., "self" or "upstream"). +func (r *Recorder) Record(category string, req *http.Request, res *http.Response) error { + if r.BaseDir == "" { + return nil + } + + // Group by URL path, sanitizing variable segments like IP addresses + pathSegments := strings.Split(strings.Trim(req.URL.Path, "/"), "/") + sanitizedSegments := make([]string, 0, len(pathSegments)) + replacements := make(map[string]string) + for _, segment := range pathSegments { + if segment == "" { + continue + } + + sanitized, replacement := r.Patterns.Sanitize(segment) + sanitizedSegments = append(sanitizedSegments, sanitized) + if replacement != "" { + replacements[segment] = replacement + } + } + + subDir := "root" + if len(sanitizedSegments) > 0 { + subDir = filepath.Join(sanitizedSegments...) + } + + dir := filepath.Join(r.BaseDir, "interactions", r.SessionID, category, subDir) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + timestamp := time.Now().Format("15-04-05.000") + count := atomic.AddUint64(&r.counter, 1) + filename := fmt.Sprintf("%04d-%s-%s.http", count, timestamp, req.Method) + path := filepath.Join(dir, filename) + + var buf bytes.Buffer + + // Write Request + displayURL := req.URL.String() + for orig, repl := range replacements { + displayURL = strings.ReplaceAll(displayURL, orig, "{{"+strings.Trim(repl, "{}")+"}}") + } + + buf.WriteString(fmt.Sprintf("### %s %s\n", req.Method, displayURL)) + buf.WriteString(fmt.Sprintf("%s %s\n", req.Method, displayURL)) + for k, vv := range req.Header { + for _, v := range vv { + val := v + for orig, repl := range replacements { + val = strings.ReplaceAll(val, orig, "{{"+strings.Trim(repl, "{}")+"}}") + } + buf.WriteString(fmt.Sprintf("%s: %s\n", k, val)) + } + } + buf.WriteString("\n") + + if req.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + if err == nil { + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + buf.Write(bodyBytes) + buf.WriteString("\n") + } + } + + // Write Response if available + if res != nil { + buf.WriteString("\n") + buf.WriteString("> {% \n") + buf.WriteString(fmt.Sprintf(" // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))) + buf.WriteString(" // Headers:\n") + for k, vv := range res.Header { + for _, v := range vv { + buf.WriteString(fmt.Sprintf(" // %s: %s\n", k, v)) + } + } + buf.WriteString("%}\n") + + if res.Body != nil { + bodyBytes, err := io.ReadAll(res.Body) + if err == nil { + res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + contentType := res.Header.Get("Content-Type") + if strings.Contains(contentType, "xml") || strings.Contains(contentType, "json") || strings.Contains(contentType, "text") { + buf.WriteString("\n/*\n") + buf.Write(bodyBytes) + buf.WriteString("\n*/\n") + } else { + buf.WriteString(fmt.Sprintf("\n// [Binary response body: %d bytes]\n", len(bodyBytes))) + } + } + } + } + + if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { + return err + } + + return r.updateEnvFile(replacements) +} + +func (r *Recorder) updateEnvFile(newVars map[string]string) error { + if len(newVars) == 0 { + return nil + } + + r.mu.Lock() + defer r.mu.Unlock() + + changed := false + for orig, repl := range newVars { + key := strings.Trim(repl, "{}") + if r.variables[key] != orig { + r.variables[key] = orig + changed = true + } + } + + if !changed { + return nil + } + + envFile := filepath.Join(r.BaseDir, "interactions", r.SessionID, "http-client.env.json") + + // Create the structure: {"session": {"key": "val"}} + content := map[string]map[string]string{ + "session": r.variables, + } + + data, err := json.MarshalIndent(content, "", " ") + if err != nil { + return err + } + + return os.WriteFile(envFile, data, 0644) +} diff --git a/pkg/service/proxy/recorder_test.go b/pkg/service/proxy/recorder_test.go new file mode 100644 index 0000000..2985e9e --- /dev/null +++ b/pkg/service/proxy/recorder_test.go @@ -0,0 +1,281 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestRecorder_Record_Structure(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + + tests := []struct { + name string + category string + path string + expected string // Expected subdirectory after interactions/{sessionID}/{category}/ + }{ + { + name: "root_path", + category: "self", + path: "/", + expected: "root", + }, + { + name: "simple_path", + category: "self", + path: "/setup/info", + expected: "setup/info", + }, + { + name: "path_with_ip", + category: "self", + path: "/setup/info/192.168.178.35", + expected: "setup/info/{ip}", + }, + { + name: "upstream_path", + category: "upstream", + path: "/v1/playback/station/s123", + expected: "v1/playback/station/s123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + Method: "GET", + URL: &url.URL{ + Path: tt.path, + }, + Header: make(http.Header), + } + + err := r.Record(tt.category, req, nil) + if err != nil { + t.Fatalf("Record failed: %v", err) + } + + expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, tt.category, tt.expected) + if _, err := os.Stat(expectedDir); os.IsNotExist(err) { + t.Errorf("Expected directory %s does not exist", expectedDir) + } + + // Check if file was created + files, _ := os.ReadDir(expectedDir) + if len(files) == 0 { + t.Errorf("No files created in %s", expectedDir) + } + for _, f := range files { + if !strings.Contains(f.Name(), "-GET.http") { + t.Errorf("Unexpected filename: %s", f.Name()) + } + // Verify prefix is 4 digits + if len(f.Name()) < 5 || !isDigit(f.Name()[0]) || !isDigit(f.Name()[1]) || !isDigit(f.Name()[2]) || !isDigit(f.Name()[3]) || f.Name()[4] != '-' { + t.Errorf("Filename %s does not have correct 0000- prefix", f.Name()) + } + } + }) + } +} + +func TestRecorder_Record_Sanitization(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-sanitization-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + // Add a custom pattern + r.Patterns = append(r.Patterns, PathPattern{ + Name: "DeviceID", + Regexp: `^A81B\w{8}$`, + Replacement: "{deviceId}", + }) + // Re-compile + for i := range r.Patterns { + re, _ := regexp.Compile(r.Patterns[i].Regexp) + r.Patterns[i].compiled = re + } + + req := &http.Request{ + Method: "GET", + URL: &url.URL{ + Path: "/info/192.168.178.35/A81B6A536A98", + }, + Header: make(http.Header), + } + req.Header.Set("X-Device", "A81B6A536A98") + + err = r.Record("self", req, nil) + if err != nil { + t.Fatalf("Record failed: %v", err) + } + + expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "info", "{ip}", "{deviceId}") + if _, err := os.Stat(expectedDir); os.IsNotExist(err) { + t.Errorf("Expected directory %s does not exist", expectedDir) + } + + files, _ := os.ReadDir(expectedDir) + if len(files) == 0 { + t.Fatalf("No files created in %s", expectedDir) + } + + content, _ := os.ReadFile(filepath.Join(expectedDir, files[0].Name())) + contentStr := string(content) + + if !strings.Contains(contentStr, "### GET /info/{{ip}}/{{deviceId}}") { + t.Errorf("Expected sanitized comment in .http file, got:\n%s", contentStr) + } + if !strings.Contains(contentStr, "GET /info/{{ip}}/{{deviceId}}") { + t.Errorf("Expected sanitized URL in .http file, got:\n%s", contentStr) + } + if !strings.Contains(contentStr, "X-Device: {{deviceId}}") { + t.Errorf("Expected sanitized Header in .http file, got:\n%s", contentStr) + } +} + +func TestRecorder_Record_Sanitization_Account(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-sanitization-account-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + // Add AccountID pattern + r.Patterns = append(r.Patterns, PathPattern{ + Name: "AccountID", + Regexp: `^\d{1,10}$`, + Replacement: "{accountId}", + }) + // Re-compile + for i := range r.Patterns { + re, _ := regexp.Compile(r.Patterns[i].Regexp) + r.Patterns[i].compiled = re + } + + req := &http.Request{ + Method: "GET", + URL: &url.URL{ + Path: "/marge/accounts/12345/full", + }, + Header: make(http.Header), + } + + err = r.Record("self", req, nil) + if err != nil { + t.Fatalf("Record failed: %v", err) + } + + expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "marge", "accounts", "{accountId}", "full") + if _, err := os.Stat(expectedDir); os.IsNotExist(err) { + t.Errorf("Expected directory %s does not exist", expectedDir) + } + + files, _ := os.ReadDir(expectedDir) + if len(files) == 0 { + t.Fatalf("No files created in %s", expectedDir) + } + + content, _ := os.ReadFile(filepath.Join(expectedDir, files[0].Name())) + contentStr := string(content) + + if !strings.Contains(contentStr, "### GET /marge/accounts/{{accountId}}/full") { + t.Errorf("Expected sanitized comment in .http file, got:\n%s", contentStr) + } + if !strings.Contains(contentStr, "GET /marge/accounts/{{accountId}}/full") { + t.Errorf("Expected sanitized URL in .http file, got:\n%s", contentStr) + } +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} + +func TestRecorder_IncreasingPrefix(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-prefix-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + req := &http.Request{ + Method: "GET", + URL: &url.URL{ + Path: "/test", + }, + Header: make(http.Header), + } + + for i := 1; i <= 3; i++ { + err := r.Record("self", req, nil) + if err != nil { + t.Fatalf("Record failed: %v", err) + } + } + + expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "test") + files, _ := os.ReadDir(expectedDir) + if len(files) != 3 { + t.Fatalf("Expected 3 files, got %d", len(files)) + } + + expectedPrefixes := []string{"0001-", "0002-", "0003-"} + for i, f := range files { + if !strings.HasPrefix(f.Name(), expectedPrefixes[i]) { + t.Errorf("File %d: expected prefix %s, got %s", i, expectedPrefixes[i], f.Name()) + } + } +} + +func TestRecorder_EnvFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-env-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + req := &http.Request{ + Method: "GET", + URL: &url.URL{ + Path: "/info/192.168.178.35", + }, + Header: make(http.Header), + } + + err = r.Record("self", req, nil) + if err != nil { + t.Fatalf("Record failed: %v", err) + } + + envFile := filepath.Join(tmpDir, "interactions", r.SessionID, "http-client.env.json") + if _, err := os.Stat(envFile); os.IsNotExist(err) { + t.Fatalf("Expected env file %s does not exist", envFile) + } + + data, _ := os.ReadFile(envFile) + var content map[string]map[string]string + if err := json.Unmarshal(data, &content); err != nil { + t.Fatalf("Failed to unmarshal env file: %v", err) + } + + if content["session"]["ip"] != "192.168.178.35" { + t.Errorf("Expected ip to be 192.168.178.35, got %s", content["session"]["ip"]) + } +}