diff --git a/Dockerfile b/Dockerfile index 070d608..2b45ac4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,9 +18,14 @@ RUN go build -o /discon-server discon-wrapper/discon-server # Create the final image FROM ubuntu:24.04 -# Install runtime dependencies +# Install runtime dependencies including C, C++, and Fortran libraries RUN apt-get update && apt-get install -y \ libc6 \ + libstdc++6 \ + libgcc-s1 \ + libgfortran5 \ + liblapack3 \ + libblas3 \ && rm -rf /var/lib/apt/lists/* # Copy the binary from the build stage diff --git a/README.md b/README.md index e6c6b60..f17e0dc 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,12 @@ There's not a lot to configure, but you're bridging two applications, so things ### Server -Download `discon-server_386.exe` from [Releases](https://github.com/deslaughter/discon-wrapper/releases) and put it in the same directory as the Controller shared library. Start the server from the command line by running `discon-server_amd64.exe --port=8080 --debug`. The `port` argument specifies which port on your computer it will listen to for client connections. This can be any 4-5 digit number that is not already in use by the operating system. If it returns an error, try a different number. The `debug` argument enables debug output which will be helpful for checking that it's working, but should be turned off when running simulations. +Download `discon-server_386.exe` from [Releases](https://github.com/deslaughter/discon-wrapper/releases) and put it in the same directory as the Controller shared library. Start the server from the command line by running `discon-server_amd64.exe --port=8080 --debug=1`. The `port` argument specifies which port on your computer it will listen to for client connections. This can be any 4-5 digit number that is not already in use by the operating system. If it returns an error, try a different number. + +The `debug` argument now accepts a level (0, 1, or 2): +- `0`: No debug output (default) +- `1`: Basic debug information +- `2`: Verbose debug output including full payloads That's it for the server configuration. It doesn't load the controller until the client makes a connection because the client has to tell it what to load, more on that in the next section. @@ -56,19 +61,38 @@ set DISCON_CLIENT_DEBUG=1 openfast.exe my_turbine.fst ``` -- `DISCON_SERVER_ADDR` describes the host and port the server is listening on. `localhost` indicates the same machine and the port, `8080`, needs to match the number that was given to the server via the `--port` argument. +- `DISCON_SERVER_ADDR` describes the server address in one of these formats: + - `hostname:port` (e.g. `localhost:8080`) - Standard WebSocket connection + - `domain.name` (e.g. `controller.company.com`) - For use with reverse proxies that handle the port internally + - `http://domain.name` - Explicit HTTP protocol, uses WebSocket (ws://) + - `https://domain.name` - Secure HTTPS protocol, uses secure WebSocket (wss://) - `DISCON_LIB_PATH` is the path from `discon-server_386.exe` to the controller shared library. - `DISCON_LIB_PROC` is the procedure which will be called in the controller shared library. - `DISCON_CLIENT_DEBUG` is used to enable debugging output on the client side, messages will be printed to the terminal. You can see that the environment variable settings correspond to the original controller settings that were in the ServoDyn input file. +## Client-Side Input Files + +DISCON-Wrapper now supports input files located on the client side. When a controller asks for an input file (such as DISCON.IN), the client will: + +1. Check if the file exists locally +2. If found, automatically transfer the file to the server before the controller is called +3. Update the file path that's passed to the controller on the server side + +This means you can now keep your input files on the client machine and don't need to manually copy them to the server. The file transfer happens automatically and transparently. + +Benefits: +- No need to copy input files to the server +- Files are transferred only once during a simulation +- Files are automatically cleaned up when the connection closes + ## Running For simplicity, put the OpenFAST input files, controller shared library, `discon-server_386.exe`, and `discon-client_amd64.dll` into a directory. Open two command prompts, one for running the server, and the other for running OpenFAST. Start the server by running the following in one command prompt ``` -discon-server_amd64.exe --port=8080 --debug +discon-server_amd64.exe --port=8080 --debug=1 ``` Switch to the second command prompt, specify the environment variables, and run OpenFAST: @@ -83,4 +107,4 @@ openfast.exe my_turbine.fst If everything is configured properly, the simulation should proceed as though OpenFAST had loaded the controller directly. Remember, that the server must be started before running the simulation because the client will attempt to connect when it is loaded. Once the simulation stops, the client will disconnect. The server is will continue running and wait for new connections so you can run more simulations. To stop the server, switch to the command prompt, and close it or press `CTRL+C` to kill the server. -You may see a temporary copy of the controller while the simulation is running. This is done so the server can load multiple copies of the controller at once, supporting multiple concurrent OpenFAST simulations. If they aren't automatically removed by the server, they can be deleted manually once the server has been stopped. +You may see temporary files created while the simulation is running. These include a copy of the controller and any transferred input files. They are automatically cleaned up when the connection closes, but if they aren't removed for some reason, they can be deleted manually once the server has been stopped. diff --git a/discon-client/client.go b/discon-client/client.go index b3d9812..98bc151 100644 --- a/discon-client/client.go +++ b/discon-client/client.go @@ -3,19 +3,25 @@ package main import "C" import ( + "crypto/tls" dw "discon-wrapper" "fmt" "log" "net/url" "os" + "path/filepath" "strconv" + "strings" "unsafe" + // GH-Cp gen: Import shared utilities + "discon-wrapper/shared/utils" + "github.com/gorilla/websocket" ) const program = "discon-client" -const version = "v0.1.0" +const version = "v0.2.0" var debugLevel int = 0 @@ -24,8 +30,155 @@ var payload dw.Payload var sentSwapFile *os.File var recvSwapFile *os.File -func init() { +// GH-Cp gen: Map to store server-side file paths for transferred files +var serverFilePaths = make(map[string]string) + +// Added map to track if a file is the primary input file +var isPrimaryInputFile = make(map[string]bool) + +// Added map for storing file content replacements +var fileContentReplacements = make(map[string][]struct { + Original string + Replaced string +}) + +// Track if additional files have been processed +var additionalFilesProcessed bool = false + +// GH-Cp gen: Logger for client operations +var logger *utils.DebugLogger + +// Process the DISCON_ADDITIONAL_FILES environment variable +func processAdditionalFiles() error { + additionalFilesStr, found := os.LookupEnv("DISCON_ADDITIONAL_FILES") + if (!found || additionalFilesStr == "") { + logger.Debug("No DISCON_ADDITIONAL_FILES specified") + return nil + } + + // Split the semicolon-separated list + additionalFiles := strings.Split(additionalFilesStr, ";") + logger.Debug("Processing %d additional files", len(additionalFiles)) + + // Process all additional files + for _, filePath := range additionalFiles { + filePath = strings.TrimSpace(filePath) + if filePath == "" { + continue + } + + if !utils.FileExists(filePath) { + return fmt.Errorf("additional file does not exist: %s", filePath) + } + + // Send the file but don't track errors - we'll collect and report them later + serverPath, err := sendFileToServer(filePath) + if err != nil { + return fmt.Errorf("failed to send additional file %s: %w", filePath, err) + } + + logger.Debug("Additional file %s transferred to server at %s", filePath, serverPath) + } + + return nil +} + +// Function to update file references in a content buffer +func updateFileReferences(content []byte) []byte { + contentStr := string(content) + + // Go through each file that might need replacement + for localPath, serverPath := range serverFilePaths { + // Skip the primary input file itself + if isPrimaryInputFile[localPath] { + continue + } + + // Also try to replace just the filename (in case only the filename is referenced) + // but only if it's not already a server path (doesn't start with "input_") + localFilename := filepath.Base(localPath) + serverFilename := filepath.Base(serverPath) + + // Only replace the filename if it's not already a server path (doesn't start with "input_") + if !strings.HasPrefix(localFilename, "input_") { + // Replace only whole words to avoid partial replacements within other words + contentStr = strings.ReplaceAll(contentStr, localFilename, serverFilename) + } + + logger.Verbose("Replaced references from %s to %s in input file", localFilename, serverFilename) + } + + return []byte(contentStr) +} + +// GH-Cp gen: Function to send file to server and get server path - refactored to use shared utilities +func sendFileToServer(filePath string) (string, error) { + // Check if we've already sent this file + if serverPath, exists := serverFilePaths[filePath]; exists { + return serverPath, nil + } + + // Read the file contents + content, err := utils.ReadFileContents(filePath) + if err != nil { + return "", err + } + + // If this is the primary input file and we have additional files transferred, + // update references to those files in the content + if isPrimaryInputFile[filePath] && len(serverFilePaths) > 0 { + logger.Debug("Updating file references in content for %s", filePath) + content = updateFileReferences(content) + } + // Generate a server path using shared utility + serverPath := utils.GenerateServerFilePath(content, filePath) + + // Create a file transfer payload using shared utility + fileTransferPayload := utils.CreateFileTransferPayload(content, serverPath) + + logger.Debug("Sending file %s to server (size: %d bytes)", filePath, len(content)) + + // Send the file transfer payload to the server + b, err := fileTransferPayload.MarshalBinary() + if err != nil { + return "", utils.FormatError("marshaling file transfer payload", err) + } + + err = ws.WriteMessage(websocket.BinaryMessage, b) + if err != nil { + return "", utils.FormatError("sending file to server", err) + } + + // Wait for server response + _, resp, err := ws.ReadMessage() + if err != nil { + return "", utils.FormatError("receiving server response", err) + } + + // Unmarshal the response + var responsePayload dw.Payload + err = responsePayload.UnmarshalBinary(resp) + if err != nil { + return "", utils.FormatError("unmarshaling server response", err) + } + + // Check if the file transfer succeeded + if responsePayload.Fail != 0 { + // Get the error message from the Msg field using shared utility + errMsg := utils.GetErrorMessageFromPayload(&responsePayload) + return "", fmt.Errorf("file transfer failed: %s", errMsg) + } + + // Store the server path for future use + serverFilePaths[filePath] = serverPath + + logger.Debug("File %s transferred successfully to server at %s", filePath, serverPath) + + return serverPath, nil +} + +func init() { // Print info fmt.Println("Loaded", program, version) @@ -54,10 +207,13 @@ func init() { } } + // GH-Cp gen: Initialize the logger + logger = utils.NewDebugLogger(debugLevel, "discon-client") + // Get discon-server address from environment variable serverAddr, found := os.LookupEnv("DISCON_SERVER_ADDR") if !found { - log.Fatal("discon-client: environment variable DISCON_SERVER_ADDR not set (e.g. 'localhost:8080')") + log.Fatal("discon-client: environment variable DISCON_SERVER_ADDR not set (e.g. 'localhost:8080' or 'https://controller.domain.com')") } // Get shared library path from environment variable @@ -72,15 +228,29 @@ func init() { log.Fatal("discon-client: environment variable DISCON_LIB_PROC not set (e.g. 'discon')") } - if debugLevel >= 1 { - log.Println("discon-client: DISCON_SERVER_ADDR=", serverAddr) - log.Println("discon-client: DISCON_LIB_PATH=", disconPath) - log.Println("discon-client: DISCON_LIB_PROC=", disconFunc) - log.Println("discon-client: DISCON_CLIENT_DEBUG=", debugLevel) + logger.Debug("DISCON_SERVER_ADDR= %s", serverAddr) + logger.Debug("DISCON_LIB_PATH= %s", disconPath) + logger.Debug("DISCON_LIB_PROC= %s", disconFunc) + logger.Debug("DISCON_CLIENT_DEBUG= %d", debugLevel) + logger.Debug("DISCON_ADDITIONAL_FILES= %s", os.Getenv("DISCON_ADDITIONAL_FILES")) + + // Determine if we're using HTTPS/WSS based on the provided server address + var wsURL string + if strings.HasPrefix(strings.ToLower(serverAddr), "http://") { + // HTTP URL provided - use ws:// + serverAddr = strings.TrimPrefix(serverAddr, "http://") + wsURL = fmt.Sprintf("ws://%s/ws", serverAddr) + } else if strings.HasPrefix(strings.ToLower(serverAddr), "https://") { + // HTTPS URL provided - use wss:// + serverAddr = strings.TrimPrefix(serverAddr, "https://") + wsURL = fmt.Sprintf("wss://%s/ws", serverAddr) + } else { + // No protocol provided, assume ws:// (non-secure) + wsURL = fmt.Sprintf("ws://%s/ws", serverAddr) } // Create a URL object - u, err := url.Parse(fmt.Sprintf("ws://%s/ws", serverAddr)) + u, err := url.Parse(wsURL) if err != nil { log.Fatal(err) } @@ -88,24 +258,28 @@ func init() { // Add query parameters for shared library path and proc u.RawQuery = url.Values{"path": {disconPath}, "proc": {disconFunc}}.Encode() - if debugLevel >= 1 { - log.Printf("discon-client: connecting to discon-server at '%s'\n", u.String()) - } + logger.Debug("connecting to discon-server at '%s'", u.String()) // Connect to websocket server - ws, _, err = websocket.DefaultDialer.Dial(u.String(), nil) + dialer := websocket.DefaultDialer + // If using wss (secure WebSocket), we might need to skip certificate verification in some cases + if strings.HasPrefix(u.String(), "wss://") { + dialer.TLSClientConfig = &tls.Config{ + // For production, you should properly handle certificates + // InsecureSkipVerify: true, // Uncomment this line to skip certificate verification (not recommended for production) + } + } + + ws, _, err = dialer.Dial(u.String(), nil) if err != nil { log.Fatalf("discon-client: error connecting to discon-server at %s: %s", serverAddr, err) } - if debugLevel >= 1 { - log.Printf("discon-client: Connected to discon-server at '%s'\n", u.String()) - } + logger.Debug("Connected to discon-server at '%s'", u.String()) } //export DISCON func DISCON(avrSwap *C.float, aviFail *C.int, accInFile, avcOutName, avcMsg *C.char) { - // Get first 130 entries of swap array swap := (*[1 << 24]float32)(unsafe.Pointer(avrSwap)) @@ -120,18 +294,105 @@ func DISCON(avrSwap *C.float, aviFail *C.int, accInFile, avcOutName, avcMsg *C.c payload.Swap = make([]float32, swapSize) } - if debugLevel >= 2 { - log.Printf("discon-client: size of avrSWAP: % 5d\n", swapSize) - log.Printf("discon-client: size of accINFILE: % 5d\n", inFileSize) - log.Printf("discon-client: size of avcOUTNAME: % 5d\n", outNameSize) - log.Printf("discon-client: size of avcMSG: % 5d\n", msgSize) + logger.Verbose("size of avrSWAP: % 5d", swapSize) + logger.Verbose("size of accINFILE: % 5d", inFileSize) + logger.Verbose("size of avcOUTNAME: % 5d", outNameSize) + logger.Verbose("size of avcMSG: % 5d", msgSize) + + // GH-Cp gen: Get the input file path from accInFile with safer handling + var inFilePath string + if accInFile != nil && inFileSize > 0 { + // Safely get the file path - ensure we don't read past the end of the array + safeSize := inFileSize + if safeSize > 1 { + safeSize-- // -1 to exclude null terminator if present + } + inFilePath = string((*[1 << 24]byte)(unsafe.Pointer(accInFile))[:safeSize]) + // Remove any null terminators from the end of the string + inFilePath = utils.SafeTrimString(inFilePath) + } + + // Process additional files before handling the main input file (but only once) + if !additionalFilesProcessed { + // Process any additional files specified via environment variable + if err := processAdditionalFiles(); err != nil { + logger.Error("Error processing additional files: %v", err) + // Set failure flag + *aviFail = C.int(1) + // Set error message + errMsg := fmt.Sprintf("Additional files transfer failed: %v", err) + copy((*[1 << 24]byte)(unsafe.Pointer(avcMsg))[:msgSize], []byte(errMsg)) + return + } + additionalFilesProcessed = true + } + + // GH-Cp gen: Check if the input file exists locally and transfer it to server if needed + if inFilePath != "" && utils.FileExists(inFilePath) { + logger.Verbose("Input file found locally: %s", inFilePath) + + // Mark this as the primary input file + isPrimaryInputFile[inFilePath] = true + + // Transfer file to server and get the server-side path + serverPath, err := sendFileToServer(inFilePath) + if err != nil { + logger.Error("Error transferring file %s: %v", inFilePath, err) + // Set failure flag + *aviFail = C.int(1) + // Set error message + errMsg := fmt.Sprintf("File transfer failed: %v", err) + copy((*[1 << 24]byte)(unsafe.Pointer(avcMsg))[:msgSize], []byte(errMsg)) + return + } + + // GH-Cp gen: Update the InFile field in payload with the server-side path + // First, create a new byte slice with the modified path + serverPathBytes := []byte(serverPath + "\x00") + + // Copy to payload.InFile + payload.InFile = serverPathBytes + + // Update the inFileSize in the swap array + swap[49] = float32(len(serverPathBytes)) + + logger.Verbose("Using server path for input file: %s", serverPath) + } else { + // Handle original path - with safety checks + if accInFile != nil && inFileSize > 0 { + if inFilePath != "" { + logger.Debug("Input file not found locally: %s, continuing with original path", inFilePath) + } + payload.InFile = (*[1 << 24]byte)(unsafe.Pointer(accInFile))[:inFileSize:inFileSize] + } else { + // Ensure we have at least an empty byte array with a null terminator + payload.InFile = []byte{0} + } } + // Fill the rest of the payload payload.Swap = swap[:swapSize:swapSize] payload.Fail = int32(*aviFail) - payload.InFile = (*[1 << 24]byte)(unsafe.Pointer(accInFile))[:inFileSize:inFileSize] - payload.OutName = (*[1 << 24]byte)(unsafe.Pointer(avcOutName))[:outNameSize:outNameSize] - payload.Msg = (*[1 << 24]byte)(unsafe.Pointer(avcMsg))[:msgSize:msgSize] + + // Safely handle output name and message with null checks + if avcOutName != nil && outNameSize > 0 { + payload.OutName = (*[1 << 24]byte)(unsafe.Pointer(avcOutName))[:outNameSize:outNameSize] + } else { + payload.OutName = []byte{0} + } + + if avcMsg != nil && msgSize > 0 { + payload.Msg = (*[1 << 24]byte)(unsafe.Pointer(avcMsg))[:msgSize:msgSize] + } else { + payload.Msg = []byte{0} + } + + // Reset file transfer fields to avoid sending unnecessary data + payload.FileContent = nil + payload.ServerFilePath = nil + + // GH-Cp gen: Ensure payload is properly formatted + utils.PreparePayloadForTransmission(&payload) // Convert payload to binary and send over websocket b, err := payload.MarshalBinary() @@ -140,9 +401,7 @@ func DISCON(avrSwap *C.float, aviFail *C.int, accInFile, avcOutName, avcMsg *C.c } ws.WriteMessage(websocket.BinaryMessage, b) - if debugLevel >= 2 { - log.Println("discon-client: sent payload:\n", payload) - } + logger.Verbose("sent payload: %v", payload) if debugLevel >= 1 && sentSwapFile != nil { outSwapSize := min(swapSize, 163) @@ -164,9 +423,7 @@ func DISCON(avrSwap *C.float, aviFail *C.int, accInFile, avcOutName, avcMsg *C.c log.Fatalf("discon-client: %s", err) } - if debugLevel >= 2 { - log.Println("discon-client: received payload:\n", payload) - } + logger.Verbose("received payload: %v", payload) if debugLevel >= 1 && recvSwapFile != nil { outSwapSize := min(swapSize, 163) diff --git a/discon-server/server.go b/discon-server/server.go index 402ffdf..a79bfc9 100644 --- a/discon-server/server.go +++ b/discon-server/server.go @@ -1,35 +1,63 @@ package main import ( + "discon-wrapper/shared/utils" "flag" "fmt" "log" "net/http" + "os" + "time" ) const program = "discon-server" -const version = "v0.1.0" +const version = "v0.2.0" -var debug = false +// GH-Cp gen: Using an int for debug levels instead of boolean +var debugLevel int = 0 var port = 8080 +var serverLogger *utils.DebugLogger func main() { + // Configure logging with timestamp, file and line number + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) // Display program and version log.Printf("Started %s %s", program, version) flag.IntVar(&port, "port", 8080, "Port to listen on") - flag.BoolVar(&debug, "debug", false, "Enable debug output") + // GH-Cp gen: Updated to use debug levels + flag.IntVar(&debugLevel, "debug", 0, "Debug level: 0=disabled, 1=basic info, 2=verbose with payloads") flag.Parse() + + // Create server-wide logger for non-connection-specific logs + serverLogger = utils.NewDebugLogger(debugLevel, "discon-server") + + serverLogger.Debug("Server initialized with debug level %d", debugLevel) + serverLogger.Debug("Hostname: %s", getHostname()) http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { - ServeWs(w, r, debug) + serverLogger.Debug("New connection request from %s", r.RemoteAddr) + start := time.Now() + ServeWs(w, r, debugLevel) + connectionDuration := time.Since(start) + serverLogger.Debug("Connection from %s closed after %v", r.RemoteAddr, connectionDuration) }) // Start server - log.Printf("Listening on port %d", port) + serverLogger.Debug("Listening on port %d", port) err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil) if err != nil { + serverLogger.Error("ListenAndServe failed: %v", err) log.Fatal("ListenAndServe: ", err) } } + +// Helper function to get hostname for better log context +func getHostname() string { + hostname, err := os.Hostname() + if err != nil { + return "unknown-host" + } + return hostname +} diff --git a/discon-server/server_test.go b/discon-server/server_test.go index af2fa68..9bf58f4 100644 --- a/discon-server/server_test.go +++ b/discon-server/server_test.go @@ -17,7 +17,7 @@ func TestServeWs(t *testing.T) { // connect handler to websocket function http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { - ServeWs(w, r, true) + ServeWs(w, r, 1) }) // Start server in separate go routine diff --git a/discon-server/websocket.go b/discon-server/websocket.go index 511b18c..260b0f2 100644 --- a/discon-server/websocket.go +++ b/discon-server/websocket.go @@ -8,16 +8,17 @@ import "C" import ( dw "discon-wrapper" "fmt" - "io" "log" "net/http" "net/url" "os" - "path/filepath" "sync" "time" "unsafe" + // GH-Cp gen: Use the shared utilities package + "discon-wrapper/shared/utils" + "github.com/gorilla/websocket" ) @@ -32,7 +33,60 @@ var connectionIDMutex sync.Mutex // WaitGroup to wait for all goroutines to finish var wg sync.WaitGroup -func ServeWs(w http.ResponseWriter, r *http.Request, debug bool) { +// GH-Cp gen: Map of temporary files created for each connection +var tempFiles = make(map[int32][]string) +var tempFilesMutex sync.Mutex + +// GH-Cp gen: Handle file transfer from client to server - updated to use shared utilities +func handleFileTransfer(connID int32, payload *dw.Payload, logger *utils.DebugLogger) (*dw.Payload, error) { + // Get server file path from payload + serverFilePath := utils.ExtractStringFromBytes(payload.ServerFilePath) + + // Validate filename for security + err := utils.ValidateFileName(serverFilePath) + if err != nil { + errMsg := fmt.Sprintf("Security error: %v", err) + response := utils.CreateFileTransferResponse(false, errMsg) + return response, fmt.Errorf("file validation error: %w", err) + } + + // Verify file contents with hash + contentHash := utils.ComputeFileHash(payload.FileContent) + + logger.Debug("Received file transfer request for %s (size: %d bytes, hash: %s)", + serverFilePath, len(payload.FileContent), contentHash[:8]) + + // Create the file with the server file path + file, err := os.Create(serverFilePath) + if err != nil { + errMsg := fmt.Sprintf("Failed to create file: %v", err) + response := utils.CreateFileTransferResponse(false, errMsg) + return response, fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + // Write the file contents + _, err = file.Write(payload.FileContent) + if err != nil { + errMsg := fmt.Sprintf("Failed to write file: %v", err) + response := utils.CreateFileTransferResponse(false, errMsg) + os.Remove(serverFilePath) // Clean up the partial file + return response, fmt.Errorf("failed to write file: %w", err) + } + + // Register the file to be cleaned up when the connection closes + tempFilesMutex.Lock() + tempFiles[connID] = append(tempFiles[connID], serverFilePath) + tempFilesMutex.Unlock() + + logger.Debug("File %s created successfully", serverFilePath) + + // Create success response + successMsg := fmt.Sprintf("File transferred successfully: %s", serverFilePath) + return utils.CreateFileTransferResponse(true, successMsg), nil +} + +func ServeWs(w http.ResponseWriter, r *http.Request, debugLevel int) { wg.Add(1) defer wg.Done() @@ -45,6 +99,9 @@ func ServeWs(w http.ResponseWriter, r *http.Request, debug bool) { } connectionIDMutex.Unlock() + // GH-Cp gen: Create a connection-specific logger with the connection ID + logger := utils.NewConnectionLogger(debugLevel, "discon-server", connID) + // Read controller path and function name from post parameters params, err := url.ParseQuery(r.URL.RawQuery) if err != nil { @@ -54,29 +111,42 @@ func ServeWs(w http.ResponseWriter, r *http.Request, debug bool) { path := params.Get("path") proc := params.Get("proc") - if debug { - log.Printf("Received request to load function '%s' from shared controller '%s'\n", proc, path) - } + logger.Debug("Received request to load function '%s' from shared controller '%s'", proc, path) // Check if controller exists at path - _, err = os.Stat(path) - if os.IsNotExist(err) { + if !utils.FileExists(path) { http.Error(w, "Controller not found at '"+path+"'", http.StatusInternalServerError) return } - // Create a copy of the shared library with a number suffix so multiple instances - // of the same library can be shared at the same time - tmpPath, err := duplicateLibrary(path, connID) + // Create a copy of the shared library with a unique suffix + tmpPath, err := utils.CreateTempFile(path, connID) if err != nil { http.Error(w, "Error duplicating controller: "+err.Error(), http.StatusInternalServerError) return } defer os.Remove(tmpPath) - if debug { - log.Printf("Duplicated controller to '%s'\n", tmpPath) - } + logger.Debug("Duplicated controller to '%s'", tmpPath) + + // GH-Cp gen: Initialize tempFiles entry for this connection + tempFilesMutex.Lock() + tempFiles[connID] = make([]string, 0) + tempFilesMutex.Unlock() + + // GH-Cp gen: Clean up any temporary files when connection closes + defer func() { + tempFilesMutex.Lock() + fileList := tempFiles[connID] + delete(tempFiles, connID) + tempFilesMutex.Unlock() + + // Remove all temporary files for this connection + for _, filePath := range fileList { + logger.Debug("Cleaning up temporary file: %s", filePath) + os.Remove(filePath) + } + }() // Load the shared library libraryPath := C.CString(tmpPath) @@ -92,9 +162,7 @@ func ServeWs(w http.ResponseWriter, r *http.Request, debug bool) { return } - if debug { - log.Printf("Library and function loaded successfully\n") - } + logger.Debug("Library and function loaded successfully") // Convert connection to a websocket ws, err := upgrader.Upgrade(w, r, nil) @@ -104,39 +172,59 @@ func ServeWs(w http.ResponseWriter, r *http.Request, debug bool) { } defer ws.Close() + // Log client connection info + logger.Debug("New WebSocket connection established from %s", ws.RemoteAddr().String()) + // Create payload structure payload := dw.Payload{} // Loop while receiving messages over socket for { - - // If not debug, set read deadline to 5 seconds + // If not in debug mode, set read deadline to 5 seconds // This will disconnect the client if no message is received in 5 seconds // which allows the controller to be unloaded and deleted - if !debug { + if debugLevel == 0 { ws.SetReadDeadline(time.Now().Add(time.Second * 5)) } // Read message from websocket messageType, b, err := ws.ReadMessage() if err != nil { - log.Println("read:", err) + logger.Debug("WebSocket read error: %v", err) break } if messageType != websocket.BinaryMessage { - log.Println("message type:", messageType) + logger.Debug("Received non-binary message type: %d", messageType) continue } err = payload.UnmarshalBinary(b) if err != nil { - log.Println("payload.UnmarshalBinary:", err) + logger.Error("Failed to unmarshal payload: %v", err) break } - if debug { - log.Println("discon-server: received payload:", payload) + // GH-Cp gen: Log received payload using the logger + logger.Verbose("received payload: %v", payload) + + // GH-Cp gen: Check if the payload is a file transfer using shared utility + if utils.IsFileTransfer(&payload) { + response, err := handleFileTransfer(connID, &payload, logger) + if err != nil { + logger.Error("handleFileTransfer: %v", err) + } + b, err = response.MarshalBinary() + if err != nil { + logger.Error("Failed to marshal response: %v", err) + break + } + err = ws.WriteMessage(websocket.BinaryMessage, b) + if err != nil { + logger.Error("Failed to write response: %v", err) + break + } + continue } // Call the function from the shared library with data in payload @@ -150,44 +238,21 @@ func ServeWs(w http.ResponseWriter, r *http.Request, debug bool) { // Convert payload to binary and send over websocket b, err = payload.MarshalBinary() if err != nil { - log.Println("payload.MarshalBinary:", err) + logger.Error("Failed to marshal payload: %v", err) break } err = ws.WriteMessage(websocket.BinaryMessage, b) if err != nil { - log.Println("write:", err) + logger.Error("Failed to write message: %v", err) break } - if debug { - fmt.Println("discon-server: sent payload:", payload) - } + // GH-Cp gen: Log sent payload using the logger + logger.Verbose("sent payload: %v", payload) } + logger.Debug("WebSocket connection closed") + // Unload the shared library C.unload_shared_library(C.int(connID)) } - -func duplicateLibrary(path string, connID int32) (string, error) { - // Create a copy of the shared library with a number suffix so multiple instances - // of the same library can be shared at the same time - outFile, err := os.CreateTemp(".", fmt.Sprintf("%s-%03d-", filepath.Base(path), connID)) - if err != nil { - return "", err - } - defer outFile.Close() - - inFile, err := os.Open(path) - if err != nil { - return "", err - } - defer inFile.Close() - - // Copy the file contents - _, err = io.Copy(outFile, inFile) - if err != nil { - return "", err - } - - return outFile.Name(), nil -} diff --git a/payload.go b/payload.go index 6687b2d..671ed94 100644 --- a/payload.go +++ b/payload.go @@ -12,7 +12,10 @@ type Payload struct { InFile []byte OutName []byte Msg []byte - buffer bytes.Buffer + // GH-Cp gen: Added fields for file transfer + FileContent []byte // Content of the controller input file + ServerFilePath []byte // Path where the file should be stored on server + buffer bytes.Buffer } func (p *Payload) MarshalBinary() ([]byte, error) { @@ -33,6 +36,15 @@ func (p *Payload) MarshalBinary() ([]byte, error) { if err != nil { return nil, err } + // GH-Cp gen: Added writing FileContent and ServerFilePath lengths + err = binary.Write(&p.buffer, binary.LittleEndian, uint32(len(p.FileContent))) + if err != nil { + return nil, err + } + err = binary.Write(&p.buffer, binary.LittleEndian, uint32(len(p.ServerFilePath))) + if err != nil { + return nil, err + } err = binary.Write(&p.buffer, binary.LittleEndian, p.Swap) if err != nil { return nil, err @@ -53,6 +65,15 @@ func (p *Payload) MarshalBinary() ([]byte, error) { if err != nil { return nil, err } + // GH-Cp gen: Added writing FileContent and ServerFilePath data + err = binary.Write(&p.buffer, binary.LittleEndian, p.FileContent) + if err != nil { + return nil, err + } + err = binary.Write(&p.buffer, binary.LittleEndian, p.ServerFilePath) + if err != nil { + return nil, err + } return p.buffer.Bytes(), nil } @@ -63,6 +84,8 @@ func (p *Payload) UnmarshalBinary(data []byte) error { // Read the lengths of the fields var swapLen, inFileLen, outNameLen, msgLen uint32 + // GH-Cp gen: Added variables for FileContent and ServerFilePath lengths + var fileContentLen, serverFilePathLen uint32 err := binary.Read(r, binary.LittleEndian, &swapLen) if err != nil { @@ -80,6 +103,15 @@ func (p *Payload) UnmarshalBinary(data []byte) error { if err != nil { return err } + // GH-Cp gen: Read lengths of FileContent and ServerFilePath + err = binary.Read(r, binary.LittleEndian, &fileContentLen) + if err != nil { + return err + } + err = binary.Read(r, binary.LittleEndian, &serverFilePathLen) + if err != nil { + return err + } // Allocate slices of the appropriate size if they don't match if len(p.Swap) != int(swapLen) { @@ -94,6 +126,13 @@ func (p *Payload) UnmarshalBinary(data []byte) error { if len(p.Msg) != int(msgLen) { p.Msg = make([]byte, msgLen) } + // GH-Cp gen: Allocate FileContent and ServerFilePath slices + if len(p.FileContent) != int(fileContentLen) { + p.FileContent = make([]byte, fileContentLen) + } + if len(p.ServerFilePath) != int(serverFilePathLen) { + p.ServerFilePath = make([]byte, serverFilePathLen) + } // Read the fields from the buffer err = binary.Read(r, binary.LittleEndian, &p.Swap) @@ -116,6 +155,15 @@ func (p *Payload) UnmarshalBinary(data []byte) error { if err != nil { return err } + // GH-Cp gen: Read FileContent and ServerFilePath data + err = binary.Read(r, binary.LittleEndian, &p.FileContent) + if err != nil { + return err + } + err = binary.Read(r, binary.LittleEndian, &p.ServerFilePath) + if err != nil { + return err + } return nil } @@ -132,14 +180,25 @@ func (p Payload) String() string { if i0Msg < 0 { i0Msg = len(p.Msg) } + // GH-Cp gen: Added handling for ServerFilePath + i0ServerFilePath := bytes.IndexByte(p.ServerFilePath, 0) + if i0ServerFilePath < 0 { + i0ServerFilePath = len(p.ServerFilePath) + } + + // GH-Cp gen: Modified string formatting to include new fields return fmt.Sprintf("avrSWAP: %v\n"+ "aviFAIL: %v\n"+ "accINFILE: '%s'\n"+ "avcOUTNAME: '%s'\n"+ - "avcMSG: '%s'\n", + "avcMSG: '%s'\n"+ + "ServerFilePath: '%s'\n"+ + "FileContent: [%d bytes]\n", p.Swap[:129], p.Fail, p.InFile[:i0InFile], p.OutName[:i0OutName], - p.Msg[:i0Msg]) + p.Msg[:i0Msg], + p.ServerFilePath[:i0ServerFilePath], + len(p.FileContent)) } diff --git a/shared/utils/file.go b/shared/utils/file.go new file mode 100644 index 0000000..9c7655c --- /dev/null +++ b/shared/utils/file.go @@ -0,0 +1,107 @@ +// Package utils provides shared utilities for both client and server +package utils + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// GH-Cp gen: FileExists checks if a file exists and is not a directory +func FileExists(filename string) bool { + // GH-Cp gen: Added nil/empty check to prevent segmentation fault + if filename == "" { + return false + } + + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +// GH-Cp gen: ReadFileContents reads the entire content of a file +func ReadFileContents(filePath string) ([]byte, error) { + if !FileExists(filePath) { + return nil, fmt.Errorf("file does not exist: %s", filePath) + } + + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("error opening file: %w", err) + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + + return content, nil +} + +// GH-Cp gen: ValidateFileName checks if a filename is safe and doesn't contain suspicious patterns +func ValidateFileName(filename string) error { + // Remove null terminators + filename = strings.ReplaceAll(filename, "\x00", "") + + // Check if filename contains suspicious patterns + suspiciousPatterns := []string{"../", "/..", "~", "$", "|", ";", "&", "\\"} + for _, pattern := range suspiciousPatterns { + if strings.Contains(filename, pattern) { + return fmt.Errorf("filename contains invalid pattern: %s", pattern) + } + } + + // Ensure filename is just a basename, not a path + if filepath.Base(filename) != filename { + return fmt.Errorf("filename must not contain path separators") + } + + return nil +} + +// GH-Cp gen: GenerateServerFilePath creates a unique server-side path for a file +func GenerateServerFilePath(content []byte, originalFilename string) string { + // Create a unique filename using SHA-256 hash of content and original filename + hash := sha256.New() + hash.Write(content) + hash.Write([]byte(filepath.Base(originalFilename))) + hashString := hex.EncodeToString(hash.Sum(nil)) + + // Generate a server path - use just the filename with a unique prefix + return fmt.Sprintf("input_%s_%s", hashString[:8], filepath.Base(originalFilename)) +} + +// GH-Cp gen: SafeTrimString removes null terminators from the end of a string +func SafeTrimString(s string) string { + return strings.TrimRight(s, "\x00") +} + +// GH-Cp gen: CreateTempFile creates a temp file with a unique name based on original path and connID +func CreateTempFile(originalPath string, connID int32) (string, error) { + outFile, err := os.CreateTemp(".", fmt.Sprintf("%s-%03d-", filepath.Base(originalPath), connID)) + if err != nil { + return "", err + } + defer outFile.Close() + + inFile, err := os.Open(originalPath) + if err != nil { + return "", err + } + defer inFile.Close() + + // Copy the file contents + _, err = io.Copy(outFile, inFile) + if err != nil { + return "", err + } + + return outFile.Name(), nil +} \ No newline at end of file diff --git a/shared/utils/logging.go b/shared/utils/logging.go new file mode 100644 index 0000000..5a89a03 --- /dev/null +++ b/shared/utils/logging.go @@ -0,0 +1,77 @@ +// Package utils provides shared utilities for both client and server +package utils + +import ( + "fmt" + "log" +) + +// GH-Cp gen: DebugLogger provides a standardized logging interface +// that respects debug levels for both client and server +type DebugLogger struct { + DebugLevel int + Prefix string + ConnectionID int32 // Added field for connection ID + HasConnID bool // Flag to indicate if this logger has a connection ID +} + +// GH-Cp gen: NewDebugLogger creates a new DebugLogger with the specified debug level and prefix +func NewDebugLogger(debugLevel int, prefix string) *DebugLogger { + return &DebugLogger{ + DebugLevel: debugLevel, + Prefix: prefix, + HasConnID: false, + } +} + +// NewConnectionLogger creates a new DebugLogger with connection ID for server-side connection logging +func NewConnectionLogger(debugLevel int, prefix string, connectionID int32) *DebugLogger { + return &DebugLogger{ + DebugLevel: debugLevel, + Prefix: prefix, + ConnectionID: connectionID, + HasConnID: true, + } +} + +// GH-Cp gen: LogAtLevel logs a message if the current debug level is at least the specified level +func (dl *DebugLogger) LogAtLevel(level int, format string, v ...interface{}) { + if dl.DebugLevel >= level { + prefix := dl.Prefix + if dl.HasConnID { + // Include connection ID in the log prefix + prefix = fmt.Sprintf("%s[conn-%d]", prefix, dl.ConnectionID) + } + + if prefix != "" { + format = prefix + ": " + format + } + log.Printf(format, v...) + } +} + +// GH-Cp gen: Debug logs at debug level 1 (basic info) +func (dl *DebugLogger) Debug(format string, v ...interface{}) { + dl.LogAtLevel(1, format, v...) +} + +// GH-Cp gen: Verbose logs at debug level 2 (verbose with payloads) +func (dl *DebugLogger) Verbose(format string, v ...interface{}) { + dl.LogAtLevel(2, format, v...) +} + +// GH-Cp gen: Error logs an error regardless of debug level +func (dl *DebugLogger) Error(format string, v ...interface{}) { + prefix := dl.Prefix + if dl.HasConnID { + // Include connection ID in the log prefix for errors too + prefix = fmt.Sprintf("%s[conn-%d]", prefix, dl.ConnectionID) + } + + if prefix != "" { + format = prefix + ": ERROR: " + format + } else { + format = "ERROR: " + format + } + log.Printf(format, v...) +} \ No newline at end of file diff --git a/shared/utils/transfer.go b/shared/utils/transfer.go new file mode 100644 index 0000000..852ea19 --- /dev/null +++ b/shared/utils/transfer.go @@ -0,0 +1,123 @@ +// Package utils provides shared utilities for both client and server +package utils + +import ( + "crypto/sha256" + dw "discon-wrapper" + "encoding/hex" + "fmt" + "strings" +) + +// GH-Cp gen: FileTransferResult holds the result of a file transfer operation +type FileTransferResult struct { + Success bool + ServerPath string + ErrorMessage string +} + +// GH-Cp gen: IsFileTransfer checks if a payload represents a file transfer +func IsFileTransfer(payload *dw.Payload) bool { + return len(payload.FileContent) > 0 && len(payload.ServerFilePath) > 0 +} + +// GH-Cp gen: CreateFileTransferPayload creates a payload for file transfer +func CreateFileTransferPayload(fileContent []byte, serverFilePath string) *dw.Payload { + return &dw.Payload{ + // Initialize required fields with empty values + Swap: make([]float32, 1), + Fail: 0, + InFile: []byte{0}, + OutName: []byte{0}, + Msg: []byte{0}, + FileContent: fileContent, + ServerFilePath: []byte(serverFilePath + "\x00"), + } +} + +// GH-Cp gen: CreateFileTransferResponse creates a response payload for a file transfer +func CreateFileTransferResponse(success bool, message string) *dw.Payload { + response := &dw.Payload{ + Swap: make([]float32, 1), + Fail: 0, + InFile: []byte{0}, + OutName: []byte{0}, + Msg: make([]byte, 256), // Reserve space for message + } + + if !success { + response.Fail = 1 + } + + // Ensure null termination + if !strings.HasSuffix(message, "\x00") { + message += "\x00" + } + + // Copy message to Msg field + copy(response.Msg, []byte(message)) + + return response +} + +// GH-Cp gen: ExtractStringFromBytes extracts a null-terminated string from a byte array +func ExtractStringFromBytes(data []byte) string { + nullIndex := strings.IndexByte(string(data), 0) + if nullIndex >= 0 { + return string(data[:nullIndex]) + } + return string(data) +} + +// GH-Cp gen: ComputeFileHash computes a SHA256 hash of file content +func ComputeFileHash(content []byte) string { + hash := sha256.New() + hash.Write(content) + return hex.EncodeToString(hash.Sum(nil)) +} + +// GH-Cp gen: GetErrorMessageFromPayload extracts an error message from a payload +func GetErrorMessageFromPayload(payload *dw.Payload) string { + if payload == nil { + return "No payload received" + } + + errMsg := string(payload.Msg) + nullIndex := strings.IndexByte(errMsg, 0) + if nullIndex >= 0 { + errMsg = errMsg[:nullIndex] + } + return errMsg +} + +// GH-Cp gen: PreparePayloadForTransmission ensures a payload is properly formatted +func PreparePayloadForTransmission(payload *dw.Payload) error { + // Check required fields + if payload.Swap == nil || len(payload.Swap) == 0 { + payload.Swap = make([]float32, 1) + } + + // Ensure all string fields are null-terminated + if len(payload.InFile) == 0 || payload.InFile[len(payload.InFile)-1] != 0 { + payload.InFile = append(payload.InFile, 0) + } + + if len(payload.OutName) == 0 || payload.OutName[len(payload.OutName)-1] != 0 { + payload.OutName = append(payload.OutName, 0) + } + + if len(payload.Msg) == 0 || payload.Msg[len(payload.Msg)-1] != 0 { + payload.Msg = append(payload.Msg, 0) + } + + if len(payload.ServerFilePath) > 0 && payload.ServerFilePath[len(payload.ServerFilePath)-1] != 0 { + payload.ServerFilePath = append(payload.ServerFilePath, 0) + } + + return nil +} + +// GH-Cp gen: FormatError creates a formatted error message for file transfer errors +func FormatError(operation string, err error) error { + return fmt.Errorf("%s failed: %w", operation, err) +} \ No newline at end of file