aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-02-18 22:17:30 +0100
committerThomas Vanbesien <tvanbesi@proton.me>2026-02-18 22:17:30 +0100
commit77e70beff33d89f30082f3e5d513cd657fa529ea (patch)
tree2943ddf1eb2709c8dc4414f93e4e8461d889cea5
parent95f40458a9dd927fba35624564b64b5f973dd9fe (diff)
downloadBobinkCOpcUa-77e70beff33d89f30082f3e5d513cd657fa529ea.tar.gz
BobinkCOpcUa-77e70beff33d89f30082f3e5d513cd657fa529ea.zip
Add download-cert client operation with integration test
Retrieves the server's DER certificate via GetEndpoints and writes it to a local file. The test starts a secure ServerLDS, downloads its certificate, and verifies it matches the original.
-rw-r--r--CMakeLists.txt6
-rw-r--r--src/client.c125
-rwxr-xr-xtests/run_download_cert_test.sh114
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