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.
506 lines
17 KiB
506 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)
|
|
}
|
|
|