Files
godoxy/internal/net
Jarek Krochmalski 1bd8b5a696 fix(middleware): restore SSE streaming for POST endpoints (regression in v0.27.0) (#206)
* fix(middleware): restore SSE streaming for POST endpoints

Regression introduced in 16935865 (v0.27.0).

Before that commit, LazyResponseModifier only buffered HTML responses and
let everything else pass through via the IsBuffered() early return. The
refactor replaced it with NewResponseModifier which unconditionally buffers
all writes until FlushRelease() fires after the handler returns. That kills
real-time streaming for any SSE endpoint that uses POST.

The existing bypass at ServeHTTP line 193 only fires when the *request*
carries Accept: text/event-stream. That works for browser EventSource (which
always sets that header) but not for programmatic fetch() calls, which set
Content-Type: application/json on the request and only emit
Content-Type: text/event-stream on the *response*.

Fix: introduce ssePassthroughWriter, a thin http.ResponseWriter wrapper that
sits in front of the ResponseModifier. It watches for Content-Type:
text/event-stream in the response headers at the moment WriteHeader or the
first Write is called. Once detected it copies the buffered headers to the
real writer and switches all subsequent writes to pass directly through with
an immediate Flush(), bypassing the ResponseModifier buffer entirely.

Also tighten the Accept header check from == to strings.Contains so that
Accept: text/event-stream, */* is handled correctly.

Reported against Dockhand (https://github.com/Finsys/dockhand) where
container update progress, image pull logs and vulnerability scan output all
stopped streaming after users upgraded to GoDoxy v0.27.0. GET SSE endpoints
(container logs) continued to work because browsers send Accept:
text/event-stream for EventSource connections.

* fix(middleware): make Content-Type SSE check case-insensitive

* refactor(middleware): extract Content-Type into a named constant

* fix(middleware): enhance safe guard to avoid buffering SSE, WS and large bodies

Reverts some changes in 16935865 and apply more rubust handling.

Use a lazy response modifier that buffers only when the response is safe
to mutate. This prevents middleware from intercepting websocket/SSE
streams, encoded payloads, and non-text or oversized responses.

Set a 4MB max buffered size and gate buffering via response headers
(content type, transfer/content encoding, and content length). Skip
mutation when a response is not buffered or mutation setup fails, and
simplify chained response modifiers to operate on the same response.

Also update the goutils submodule for max body limit support.

---------

Co-authored-by: yusing <yusing.wys@gmail.com>
2026-02-28 17:15:41 +08:00
..
2025-04-24 15:02:31 +08:00

internal/net

The net package provides network utility functions for GoDoxy, including TCP connection testing and network-related helpers.

Overview

The net package implements network utility functions that are used throughout GoDoxy for connectivity testing, TCP operations, and network-related utilities.

Key Features

  • TCP connection testing (ping)
  • Connection utilities

Core Functions

TCP Ping

// PingTCP pings a TCP endpoint by attempting a connection.
func PingTCP(ctx context.Context, ip net.IP, port int) error

Usage

Basic Usage

import "github.com/yusing/godoxy/internal/net"

func checkService(ctx context.Context, ip string, port int) error {
    addr := net.ParseIP(ip)
    if addr == nil {
        return fmt.Errorf("invalid IP: %s", ip)
    }

    err := net.PingTCP(ctx, addr, port)
    if err != nil {
        return fmt.Errorf("service %s:%d unreachable: %w", ip, port, err)
    }

    fmt.Printf("Service %s:%d is reachable\n", ip, port)
    return nil
}

Timeout Usage

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

ip := net.ParseIP("192.168.1.100")
err := net.PingTCP(ctx, ip, 8080)

if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        log.Println("Connection timed out")
    } else {
        log.Printf("Connection failed: %v", err)
    }
}

Implementation

func PingTCP(ctx context.Context, ip net.IP, port int) error {
    var dialer net.Dialer
    conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", ip, port))
    if err != nil {
        return err
    }
    conn.Close()
    return nil
}

Data Flow

sequenceDiagram
    participant Caller
    participant Dialer
    participant TCPEndpoint
    participant Connection

    Caller->>Dialer: DialContext("tcp", "ip:port")
    Dialer->>TCPEndpoint: SYN
    TCPEndpoint-->>Dialer: SYN-ACK
    Dialer->>Connection: Create connection
    Connection-->>Dialer: Connection
    Dialer-->>Caller: nil error

    Note over Caller,Connection: Connection immediately closed
    Connection->>TCPEndpoint: FIN
    TCPEndpoint-->>Connection: FIN-ACK

Use Cases

Service Health Check

func checkServices(ctx context.Context, services []Service) error {
    for _, svc := range services {
        ip := net.ParseIP(svc.IP)
        if ip == nil {
            return fmt.Errorf("invalid IP for %s: %s", svc.Name, svc.IP)
        }

        if err := net.PingTCP(ctx, ip, svc.Port); err != nil {
            return fmt.Errorf("service %s (%s:%d) unreachable: %w",
                svc.Name, svc.IP, svc.Port, err)
        }
    }
    return nil
}

Proxmox Container Reachability

// Check if a Proxmox container is reachable on its proxy port
func checkContainerReachability(ctx context.Context, node *proxmox.Node, vmid int, port int) error {
    ips, err := node.LXCGetIPs(ctx, vmid)
    if err != nil {
        return err
    }

    for _, ip := range ips {
        if err := net.PingTCP(ctx, ip, port); err == nil {
            return nil // Found reachable IP
        }
    }

    return fmt.Errorf("no reachable IP found for container %d", vmid)
}
  • Route: Uses TCP ping for load balancing health checks
  • Proxmox: Uses TCP ping to verify container reachability
  • Idlewatcher: Uses TCP ping to check idle status