diff options
| -rw-r--r-- | CMakeLists.txt | 6 | ||||
| -rw-r--r-- | src/client.c | 125 | ||||
| -rwxr-xr-x | tests/run_download_cert_test.sh | 114 |
3 files changed, 238 insertions, 7 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 78b8711..5b117b6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,3 +78,9 @@ foreach(_name _policy IN ZIP_LISTS _test_names _test_policies) set_tests_properties("${_name}" PROPERTIES WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" TIMEOUT 30) endforeach() + +add_test(NAME "download_cert" + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/run_download_cert_test.sh" + "tests/secure_anonymous") +set_tests_properties( + "download_cert" PROPERTIES WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" TIMEOUT 30) diff --git a/src/client.c b/src/client.c index ed8b12a..011792e 100644 --- a/src/client.c +++ b/src/client.c @@ -2,10 +2,11 @@ * @file client.c * @brief Unified OPC UA client for discovery and server interaction. * - * Supports three operations selected via CLI: + * Supports four operations selected via CLI: * find-servers — queries a server's FindServers service * get-endpoints — queries a server's GetEndpoints service * read-time — connects to a server and reads the current time + * download-cert — downloads a server's certificate to a local file * * Encryption is optional: when certificate, privateKey, and trustStore are * provided, the client uses the configured security policy; otherwise it @@ -19,6 +20,7 @@ #include <open62541/client_highlevel.h> #include <open62541/plugin/log_stdout.h> +#include <stdio.h> #include <stdlib.h> #include <string.h> @@ -31,6 +33,7 @@ typedef enum OP_FIND_SERVERS, OP_GET_ENDPOINTS, OP_READ_TIME, + OP_DOWNLOAD_CERT, OP_INVALID } Operation; @@ -43,6 +46,8 @@ parseOperation (const char *name) return OP_GET_ENDPOINTS; if (strcmp (name, "read-time") == 0) return OP_READ_TIME; + if (strcmp (name, "download-cert") == 0) + return OP_DOWNLOAD_CERT; return OP_INVALID; } @@ -154,6 +159,80 @@ opReadTime (UA_Client *client, const char *url) return rc; } +/** + * Downloads the server's certificate via GetEndpoints and writes it to a file. + * + * Picks the first endpoint that carries a non-empty serverCertificate. + * + * @return EXIT_SUCCESS on success, EXIT_FAILURE otherwise. + */ +static int +opDownloadCert (UA_Client *client, const char *url, const char *outputPath) +{ + size_t arraySize = 0; + UA_EndpointDescription *array = NULL; + + UA_StatusCode retval + = UA_Client_getEndpoints (client, url, &arraySize, &array); + if (retval != UA_STATUSCODE_GOOD) + { + UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_CLIENT, + "GetEndpoints failed: %s", UA_StatusCode_name (retval)); + return EXIT_FAILURE; + } + + UA_ByteString *cert = NULL; + for (size_t i = 0; i < arraySize; i++) + { + if (array[i].serverCertificate.length > 0) + { + cert = &array[i].serverCertificate; + break; + } + } + + if (!cert) + { + UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_CLIENT, + "No endpoint returned a server certificate"); + UA_Array_delete (array, arraySize, + &UA_TYPES[UA_TYPES_ENDPOINTDESCRIPTION]); + return EXIT_FAILURE; + } + + FILE *fp = fopen (outputPath, "wb"); + if (!fp) + { + UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_CLIENT, + "Could not open output file: %s", outputPath); + UA_Array_delete (array, arraySize, + &UA_TYPES[UA_TYPES_ENDPOINTDESCRIPTION]); + return EXIT_FAILURE; + } + + size_t written = fwrite (cert->data, 1, cert->length, fp); + fclose (fp); + + int rc; + if (written != cert->length) + { + UA_LOG_ERROR (UA_Log_Stdout, UA_LOGCATEGORY_CLIENT, + "Failed to write certificate (%zu of %zu bytes)", written, + cert->length); + rc = EXIT_FAILURE; + } + else + { + UA_LOG_INFO (UA_Log_Stdout, UA_LOGCATEGORY_CLIENT, + "Certificate saved to %s (%zu bytes)", outputPath, + cert->length); + rc = EXIT_SUCCESS; + } + + UA_Array_delete (array, arraySize, &UA_TYPES[UA_TYPES_ENDPOINTDESCRIPTION]); + return rc; +} + /* ======================================================================== * Main * ======================================================================== */ @@ -161,13 +240,16 @@ opReadTime (UA_Client *client, const char *url) int main (int argc, char **argv) { - if (argc < 4 || argc > 5) + if (argc < 4) { UA_LOG_FATAL (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND, "Usage: %s <config-file> <operation> <endpoint-url> " "[log-level]\n" - "Operations: find-servers, get-endpoints, read-time", - argv[0]); + " %s <config-file> download-cert <endpoint-url> " + "<output-file> [log-level]\n" + "Operations: find-servers, get-endpoints, read-time, " + "download-cert", + argv[0], argv[0]); return EXIT_FAILURE; } @@ -175,15 +257,41 @@ main (int argc, char **argv) if (op == OP_INVALID) { UA_LOG_FATAL (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND, - "Unknown operation: %s " - "(expected find-servers, get-endpoints, read-time)", + "Unknown operation: %s (expected find-servers, " + "get-endpoints, read-time, download-cert)", argv[2]); return EXIT_FAILURE; } const char *endpointUrl = argv[3]; + const char *outputPath = NULL; + const char *logLevelStr = "info"; - const char *logLevelStr = (argc == 5) ? argv[4] : "info"; + if (op == OP_DOWNLOAD_CERT) + { + if (argc < 5 || argc > 6) + { + UA_LOG_FATAL (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND, + "Usage: %s <config-file> download-cert " + "<endpoint-url> <output-file> [log-level]", + argv[0]); + return EXIT_FAILURE; + } + outputPath = argv[4]; + logLevelStr = (argc == 6) ? argv[5] : "info"; + } + else + { + if (argc > 5) + { + UA_LOG_FATAL (UA_Log_Stdout, UA_LOGCATEGORY_USERLAND, + "Usage: %s <config-file> <operation> <endpoint-url> " + "[log-level]", + argv[0]); + return EXIT_FAILURE; + } + logLevelStr = (argc == 5) ? argv[4] : "info"; + } int logLevel = parseLogLevel (logLevelStr); if (logLevel < 0) { @@ -262,6 +370,9 @@ main (int argc, char **argv) case OP_READ_TIME: rc = opReadTime (client, endpointUrl); break; + case OP_DOWNLOAD_CERT: + rc = opDownloadCert (client, endpointUrl, outputPath); + break; default: rc = EXIT_FAILURE; break; diff --git a/tests/run_download_cert_test.sh b/tests/run_download_cert_test.sh new file mode 100755 index 0000000..9bcc750 --- /dev/null +++ b/tests/run_download_cert_test.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------- +# Integration test for the download-cert client operation. +# +# Starts a secure ServerLDS, downloads its certificate via the +# client's download-cert operation, and compares the downloaded +# file with the original certificate on disk. +# +# Usage: tests/run_download_cert_test.sh <config_dir> +# +# Exit: 0 when all checks pass, 1 on any failure. +# --------------------------------------------------------------- +set -uo pipefail + +CONFIG_DIR="${1:?Usage: $0 <config_dir>}" + +LDS_PORT=14840 +LDS_PID="" +TMPFILE="" +DOWNLOADED_CERT="" +FAILURES=0 + +# ── ensure certificates exist ───────────────────────────────── +CERT_DIR=certs +GEN_CERT=tools/generate_certificate.sh + +for identity in ServerLDS Client; do + if [ ! -f "$CERT_DIR/${identity}_cert.der" ]; then + "$GEN_CERT" "$CERT_DIR" "$identity" + fi +done + +for store in server_lds client; do + mkdir -p "$CERT_DIR/trust/$store" + for identity in ServerLDS Client; do + cert="$CERT_DIR/${identity}_cert.der" + [ -f "$cert" ] && cp -n "$cert" "$CERT_DIR/trust/$store/" + done +done + +# ── cleanup ──────────────────────────────────────────────────── +cleanup() { + [ -n "$LDS_PID" ] && kill "$LDS_PID" 2>/dev/null && wait "$LDS_PID" 2>/dev/null + [ -n "$TMPFILE" ] && rm -f "$TMPFILE" + [ -n "$DOWNLOADED_CERT" ] && rm -f "$DOWNLOADED_CERT" +} +trap cleanup EXIT + +# ── helpers ──────────────────────────────────────────────────── +wait_for_port() { + local port="$1" pid="$2" label="$3" i=0 + while [ $i -lt 50 ]; do + if ! kill -0 "$pid" 2>/dev/null; then + echo "FAIL: $label exited prematurely" + exit 1 + fi + if ss -tlnp 2>/dev/null | grep -q ":${port} "; then + return 0 + fi + sleep 0.1 + i=$((i + 1)) + done + echo "FAIL: $label did not listen on port $port within 5 s" + exit 1 +} + +check() { + local label="$1" result="$2" + if [ "$result" -eq 0 ]; then + echo "PASS: $label" + else + echo "FAIL: $label" + FAILURES=$((FAILURES + 1)) + fi +} + +# ── port check ───────────────────────────────────────────────── +if ss -tlnp 2>/dev/null | grep -q ":${LDS_PORT} "; then + echo "FAIL: port $LDS_PORT is already in use" + exit 1 +fi + +# ── start LDS ────────────────────────────────────────────────── +build/ServerLDS "$CONFIG_DIR/server_lds.conf" >/dev/null 2>&1 & +LDS_PID=$! +wait_for_port "$LDS_PORT" "$LDS_PID" "ServerLDS" + +# ── download certificate ─────────────────────────────────────── +TMPFILE=$(mktemp) +DOWNLOADED_CERT=$(mktemp --suffix=.der) + +build/Client "$CONFIG_DIR/client.conf" download-cert "opc.tcp://localhost:$LDS_PORT" "$DOWNLOADED_CERT" >"$TMPFILE" 2>&1 +DC_RC=$? +DC_OUTPUT=$(<"$TMPFILE") + +[ "$DC_RC" -eq 0 ] +check "download-cert exit code is 0 (got $DC_RC)" $? + +echo "$DC_OUTPUT" | grep -q "Certificate saved to" +check "download-cert output contains 'Certificate saved to'" $? + +# ── compare with original ───────────────────────────────────── +cmp -s "$DOWNLOADED_CERT" "certs/ServerLDS_cert.der" +check "downloaded certificate matches certs/ServerLDS_cert.der" $? + +# ── result ───────────────────────────────────────────────────── +if [ "$FAILURES" -ne 0 ]; then + echo "" + echo "--- download-cert output ---" + echo "$DC_OUTPUT" + echo "--- end ---" + exit 1 +fi +exit 0 |
