You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

507 lines
17 KiB

// tera-target-session-load exercises the deployed Direct Mode WebSocket path.
// It deliberately uses the same Origin and subprotocol requirements as the web UI.
package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/websocket"
"tera-clone/services/tera-api/internal/directstream"
"tera-clone/services/tera-api/internal/domain"
)
type options struct {
baseURL *url.URL
origin string
username string
password string
access string
attachments int
startChannel int
sendPayload []byte
hold time.Duration
tlsConfig *tls.Config
}
type client struct {
http *http.Client
base *url.URL
token string
}
type loadConnection struct {
session domain.DirectSession
socket *websocket.Conn
done chan error
}
type report struct {
State string `json:"state"`
Attachments int `json:"attachments"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
HoldSeconds int `json:"hold_seconds"`
SessionDetail string `json:"session_service_detail,omitempty"`
WelcomeFrames int `json:"welcome_frames"`
SessionLogs int `json:"session_logs"`
TXBytes int `json:"tx_bytes,omitempty"`
TXLogs int `json:"tx_logs,omitempty"`
}
type serviceStatus struct {
Service string `json:"service"`
Detail string `json:"detail"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "target Direct Mode load failed: %v\n", err)
os.Exit(1)
}
}
func run() error {
configuration, err := parseOptions()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
api := &client{
http: &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{TLSClientConfig: configuration.tlsConfig}},
base: configuration.baseURL,
}
login := domain.AuthSession{}
if err := api.request(ctx, http.MethodPost, "/api/v1/auth/login", domain.AuthLoginRequest{
Username: configuration.username,
Password: configuration.password,
}, &login); err != nil {
return err
}
api.token = login.SessionToken
defer func() {
logoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = api.request(logoutCtx, http.MethodPost, "/api/v1/auth/logout", nil, nil)
}()
startedAt := time.Now().UTC()
connections := make([]loadConnection, 0, configuration.attachments)
defer func() { cleanup(connections) }()
for index := 0; index < configuration.attachments; index++ {
if err := ctx.Err(); err != nil {
return err
}
channel := fmt.Sprintf("CH%02d", (configuration.startChannel-1+index)%48+1)
session := domain.DirectSession{}
if err := api.request(ctx, http.MethodPost, "/api/v1/direct/"+channel+"/sessions", domain.DirectSessionRequest{
Access: configuration.access,
}, &session); err != nil {
return fmt.Errorf("create attachment %d/%d: %w", index+1, configuration.attachments, err)
}
socket, readDone, err := connect(ctx, configuration, session)
if err != nil {
return fmt.Errorf("connect attachment %d/%d (%s): %w", index+1, configuration.attachments, channel, err)
}
// The ticket is a one-use secret and is no longer needed after authentication.
session.StreamTicket = ""
connections = append(connections, loadConnection{session: session, socket: socket, done: readDone})
}
sessionLogs, err := validateSessionLogs(ctx, api, connections)
if err != nil {
return err
}
txLogs := 0
if len(configuration.sendPayload) > 0 {
if err := connections[0].socket.WriteMessage(websocket.BinaryMessage, configuration.sendPayload); err != nil {
return fmt.Errorf("send Direct Mode payload: %w", err)
}
txLogs, err = validateTrafficLog(ctx, api, connections[0].session.Channel, "TX", configuration.sendPayload, startedAt)
if err != nil {
return err
}
}
serviceDetail := api.sessionServiceDetail(ctx)
writeReport(report{
State: "holding", Attachments: len(connections), StartedAt: startedAt,
HoldSeconds: int(configuration.hold.Seconds()), SessionDetail: serviceDetail,
WelcomeFrames: len(connections), SessionLogs: sessionLogs,
TXBytes: len(configuration.sendPayload), TXLogs: txLogs,
})
timer := time.NewTimer(configuration.hold)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
validatedConnections := len(connections)
cleanup(connections)
connections = nil
if err := waitForSessionCleanup(ctx, api); err != nil {
return err
}
completedAt := time.Now().UTC()
writeReport(report{
State: "completed", Attachments: validatedConnections, StartedAt: startedAt,
CompletedAt: &completedAt, HoldSeconds: int(configuration.hold.Seconds()),
SessionDetail: api.sessionServiceDetail(context.Background()),
WelcomeFrames: validatedConnections, SessionLogs: sessionLogs,
TXBytes: len(configuration.sendPayload), TXLogs: txLogs,
})
return nil
default:
}
for index := range connections {
select {
case readErr := <-connections[index].done:
if readErr == nil {
readErr = errors.New("connection closed unexpectedly")
}
return fmt.Errorf("attachment %d/%d (%s) ended during hold: %w", index+1, len(connections), connections[index].session.Channel, readErr)
default:
}
}
time.Sleep(100 * time.Millisecond)
}
}
func parseOptions() (options, error) {
baseValue := flag.String("base-url", "http://192.168.0.223:8083", "target web base URL")
originValue := flag.String("origin", "", "WebSocket Origin (defaults to the base URL origin)")
username := flag.String("username", "terauser", "web username")
access := flag.String("access", directstream.AccessViewer, "Direct Mode access: viewer or writer")
attachments := flag.Int("attachments", 192, "number of viewer attachments: 1, 48, 96, or 192")
startChannel := flag.Int("start-channel", 1, "first Direct Mode channel: 1-48")
sendText := flag.String("send-text", "", "bounded UTF-8 payload sent by one writer attachment")
sendCR := flag.Bool("send-cr", false, "append carriage return to send-text")
holdSeconds := flag.Int("hold-seconds", 60, "hold time in seconds")
tlsCA := flag.String("tls-ca", "", "PEM CA certificate used to verify an HTTPS/WSS target")
flag.Parse()
password := os.Getenv("TERA_TARGET_PASSWORD")
if password == "" {
return options{}, errors.New("TERA_TARGET_PASSWORD is required")
}
allowedCount := *attachments == 1 || *attachments == 48 || *attachments == 96 || *attachments == 192
if !allowedCount {
return options{}, errors.New("attachments must be 1, 48, 96, or 192")
}
if *startChannel < 1 || *startChannel > 48 {
return options{}, errors.New("start-channel must be between 1 and 48")
}
if *access != directstream.AccessViewer && *access != directstream.AccessWriter {
return options{}, errors.New("access must be viewer or writer")
}
sendPayload := []byte(*sendText)
if *sendCR {
sendPayload = append(sendPayload, '\r')
}
if len(sendPayload) > directstream.MaxDataFrameBytes {
return options{}, fmt.Errorf("send-text payload must not exceed %d bytes", directstream.MaxDataFrameBytes)
}
if len(sendPayload) > 0 && (*access != directstream.AccessWriter || *attachments != 1) {
return options{}, errors.New("send-text requires access=writer and attachments=1")
}
if *holdSeconds < 1 || *holdSeconds > 3600 {
return options{}, errors.New("hold-seconds must be between 1 and 3600")
}
base, err := url.Parse(*baseValue)
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
return options{}, errors.New("base-url must be an absolute HTTP or HTTPS URL")
}
base.Path, base.RawPath, base.RawQuery, base.Fragment = "", "", "", ""
origin := strings.TrimSpace(*originValue)
if origin == "" {
origin = base.Scheme + "://" + base.Host
}
tlsConfig, err := trustedTLSConfig(base, strings.TrimSpace(*tlsCA))
if err != nil {
return options{}, err
}
return options{
baseURL: base, origin: origin, username: *username, password: password, access: *access,
attachments: *attachments, startChannel: *startChannel, hold: time.Duration(*holdSeconds) * time.Second,
sendPayload: sendPayload, tlsConfig: tlsConfig,
}, nil
}
func trustedTLSConfig(base *url.URL, caPath string) (*tls.Config, error) {
if base.Scheme != "https" {
if caPath != "" {
return nil, errors.New("tls-ca is only valid with an HTTPS base URL")
}
return nil, nil
}
if caPath == "" {
return nil, nil
}
payload, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read tls-ca: %w", err)
}
roots, err := x509.SystemCertPool()
if err != nil || roots == nil {
roots = x509.NewCertPool()
}
if !roots.AppendCertsFromPEM(payload) {
return nil, errors.New("tls-ca does not contain a PEM certificate")
}
return &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}, nil
}
func (c *client) request(ctx context.Context, method, path string, requestBody, responseBody any) error {
var body io.Reader
if requestBody != nil {
encoded, err := json.Marshal(requestBody)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
reference, err := url.Parse(path)
if err != nil {
return err
}
endpoint := c.base.ResolveReference(reference)
request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
if err != nil {
return err
}
if requestBody != nil {
request.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
request.Header.Set("Authorization", "Bearer "+c.token)
}
response, err := c.http.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return err
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%s %s returned HTTP %d: %s", method, path, response.StatusCode, strings.TrimSpace(string(payload)))
}
if responseBody == nil || len(payload) == 0 {
return nil
}
if err := json.Unmarshal(payload, responseBody); err != nil {
return fmt.Errorf("decode %s %s response: %w", method, path, err)
}
return nil
}
func (c *client) sessionServiceDetail(ctx context.Context) string {
status := []serviceStatus{}
if err := c.request(ctx, http.MethodGet, "/api/v1/system/services?limit=100", nil, &status); err != nil {
return "unavailable: " + err.Error()
}
for _, service := range status {
if service.Service == "tera-sessiond" {
return service.Detail
}
}
return "tera-sessiond missing"
}
func waitForSessionCleanup(ctx context.Context, api *client) error {
deadline := time.Now().Add(10 * time.Second)
for {
detail := api.sessionServiceDetail(ctx)
if strings.Contains(detail, "attachments=0") && strings.Contains(detail, "active_channels=0") && strings.Contains(detail, "serial_leases=0") {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("Direct Mode resources did not return to zero: %s", detail)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
func connect(ctx context.Context, configuration options, session domain.DirectSession) (*websocket.Conn, chan error, error) {
streamURL, err := configuration.baseURL.Parse(session.StreamURL)
if err != nil {
return nil, nil, err
}
if streamURL.Scheme == "https" {
streamURL.Scheme = "wss"
} else {
streamURL.Scheme = "ws"
}
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
Subprotocols: []string{session.StreamProtocol},
TLSClientConfig: configuration.tlsConfig,
}
header := http.Header{"Origin": []string{configuration.origin}}
socket, response, err := dialer.DialContext(ctx, streamURL.String(), header)
if err != nil {
status := 0
if response != nil {
status = response.StatusCode
}
return nil, nil, fmt.Errorf("WebSocket upgrade returned HTTP %d: %w", status, err)
}
if socket.Subprotocol() != session.StreamProtocol {
socket.Close()
return nil, nil, fmt.Errorf("negotiated subprotocol %q, want %q", socket.Subprotocol(), session.StreamProtocol)
}
if err := socket.WriteJSON(directstream.ControlFrame{Type: directstream.ControlAuthenticate, Ticket: session.StreamTicket}); err != nil {
socket.Close()
return nil, nil, err
}
_ = socket.SetReadDeadline(time.Now().Add(10 * time.Second))
var status directstream.WebSocketStatusFrame
if err := socket.ReadJSON(&status); err != nil {
socket.Close()
return nil, nil, err
}
if status.Type != "session.status" || status.Channel != session.Channel || status.SessionID != session.SessionID {
socket.Close()
return nil, nil, fmt.Errorf("unexpected authentication status type=%q channel=%q", status.Type, status.Channel)
}
messageType, welcome, err := socket.ReadMessage()
if err != nil {
socket.Close()
return nil, nil, err
}
if messageType != websocket.BinaryMessage ||
!strings.Contains(string(welcome), "Welcome to TERA CORA-X48 PRO K") ||
!strings.Contains(string(welcome), "Serial channel: "+session.Channel) ||
!strings.Contains(string(welcome), "Session: "+session.SessionID) ||
!messageContainsSourceIP(string(welcome)) {
socket.Close()
return nil, nil, fmt.Errorf("invalid Direct Mode welcome frame for %s", session.Channel)
}
_ = socket.SetReadDeadline(time.Time{})
done := make(chan error, 1)
go func() {
for {
if _, _, err := socket.ReadMessage(); err != nil {
done <- err
return
}
}
}()
return socket, done, nil
}
func validateSessionLogs(ctx context.Context, api *client, connections []loadConnection) (int, error) {
expected := make(map[string]map[string]bool)
for _, connection := range connections {
if expected[connection.session.Channel] == nil {
expected[connection.session.Channel] = make(map[string]bool)
}
expected[connection.session.Channel][connection.session.SessionID] = false
}
matched := 0
for channel, sessions := range expected {
events := []domain.PortLogEvent{}
path := "/api/v1/logs/port?channel=" + url.QueryEscape(channel) + "&event_type=session&limit=100"
if err := api.request(ctx, http.MethodGet, path, nil, &events); err != nil {
return matched, fmt.Errorf("read %s session logs: %w", channel, err)
}
for _, event := range events {
if event.EventType != "session" || !strings.Contains(event.Message, "Welcome to TERA CORA-X48 PRO K") ||
!strings.Contains(event.Message, "Serial channel: "+channel) || !messageContainsSourceIP(event.Message) {
continue
}
for sessionID, found := range sessions {
if !found && strings.Contains(event.Message, "Session: "+sessionID) {
sessions[sessionID] = true
matched++
}
}
}
for sessionID, found := range sessions {
if !found {
return matched, fmt.Errorf("session log missing for %s %s", channel, sessionID)
}
}
}
return matched, nil
}
func validateTrafficLog(ctx context.Context, api *client, channel, direction string, payload []byte, since time.Time) (int, error) {
deadline := time.Now().Add(5 * time.Second)
for {
events := []domain.PortLogEvent{}
path := "/api/v1/logs/port?channel=" + url.QueryEscape(channel) + "&event_type=traffic&direction=" + url.QueryEscape(direction) + "&limit=100"
if err := api.request(ctx, http.MethodGet, path, nil, &events); err != nil {
return 0, fmt.Errorf("read %s traffic logs: %w", channel, err)
}
matched := 0
for _, event := range events {
if event.Channel == channel && event.EventType == "traffic" && event.Direction == direction &&
event.PayloadBytes == len(payload) && !event.Timestamp.Before(since.Add(-5*time.Second)) &&
(len(event.Payload) == 0 || bytes.Equal(event.Payload, payload)) {
matched++
}
}
if matched > 0 {
return matched, nil
}
if time.Now().After(deadline) {
return 0, fmt.Errorf("%s %s traffic log for %d bytes was not observed", channel, direction, len(payload))
}
select {
case <-ctx.Done():
return 0, ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
func messageContainsSourceIP(message string) bool {
const marker = "Connected from: "
index := strings.Index(message, marker)
if index < 0 {
return false
}
fields := strings.Fields(message[index+len(marker):])
return len(fields) > 0 && net.ParseIP(fields[0]) != nil
}
func cleanup(connections []loadConnection) {
for index := range connections {
connection := &connections[index]
_ = connection.socket.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "load test complete"), time.Now().Add(time.Second))
_ = connection.socket.Close()
}
}
func writeReport(value report) {
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
_ = encoder.Encode(value)
}