package sessionhealth import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "time" "tera-clone/services/tera-api/internal/directstream" "tera-clone/services/tera-api/internal/serial" ) const maxStatusResponseBytes = 64 << 10 type Status struct { Status string `json:"status"` Broker directstream.BrokerSnapshot `json:"broker"` WebSocket directstream.WebSocketSnapshot `json:"websocket"` Tickets directstream.TicketServiceSnapshot `json:"tickets"` Serial serial.StreamSnapshot `json:"serial"` TicketError string `json:"ticket_error,omitempty"` SerialError string `json:"serial_error,omitempty"` } type Controller interface { Status(context.Context) (Status, error) } type HTTPClient struct { endpoint string client *http.Client } func NewHTTPClient(endpoint string) (*HTTPClient, error) { parsed, err := url.Parse(strings.TrimSpace(endpoint)) if err != nil || parsed.Scheme != "http" || parsed.User != nil || parsed.Host == "" { return nil, errors.New("sessiond status URL must be an unauthenticated HTTP loopback URL") } host := parsed.Hostname() ip := net.ParseIP(host) if !strings.EqualFold(host, "localhost") && (ip == nil || !ip.IsLoopback()) { return nil, errors.New("sessiond status URL host must be loopback") } if parsed.RawQuery != "" || parsed.Fragment != "" { return nil, errors.New("sessiond status URL must not contain a query or fragment") } return &HTTPClient{ endpoint: parsed.String(), client: &http.Client{ Timeout: 2 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("sessiond status redirects are not allowed") }, }, }, nil } func (c *HTTPClient) Status(ctx context.Context) (Status, error) { if c == nil || c.client == nil { return Status{}, errors.New("sessiond status client is unavailable") } request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint, nil) if err != nil { return Status{}, fmt.Errorf("create sessiond status request: %w", err) } response, err := c.client.Do(request) if err != nil { return Status{}, fmt.Errorf("read sessiond status: %w", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusServiceUnavailable { return Status{}, fmt.Errorf("sessiond status returned HTTP %d", response.StatusCode) } decoder := json.NewDecoder(io.LimitReader(response.Body, maxStatusResponseBytes)) decoder.DisallowUnknownFields() var status Status if err := decoder.Decode(&status); err != nil { return Status{}, fmt.Errorf("decode sessiond status: %w", err) } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return Status{}, errors.New("sessiond status must contain one JSON value") } if status.Status != "ok" && status.Status != "degraded" { return Status{}, errors.New("sessiond status value is invalid") } return status, nil } type UnavailableController struct { Reason string } func (c UnavailableController) Status(context.Context) (Status, error) { reason := strings.TrimSpace(c.Reason) if reason == "" { reason = "sessiond status endpoint is unavailable" } return Status{}, errors.New(reason) }