Skip to content

Commit 85d43a0

Browse files
andyclaude
andcommitted
Merge wip/236-tls-server: TLS for the central server — https data server (8443) and MQTT over TLS (8883), pinned or CA trust, no plain network listener with TLS on, ecce-remote-setup --tls (#236)
Includes the TLS client socket (wip/236-tls-client). Verified on the merged tree: tls_client, tls_server (23 checks), filedsi, csh2sh, session_end.py (three deployment modes, 0 failures), tests/teaching (0 failing, 1582 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFPK14wBcLD1s4dYUowpP4
2 parents 5b73e60 + 436cf8b commit 85d43a0

36 files changed

Lines changed: 1417 additions & 79 deletions

‎CMakeLists.txt‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ ecce_library(eccetdat
226226
# --- src/dsm -> 5 separate libraries, matching the old per-subdir mapping ---
227227
ecce_library(eccexml src/dsm/chemistry src/dsm/xml)
228228
ecce_library(eccecipc src/dsm/cipc)
229+
find_package(OpenSSL REQUIRED)
230+
target_link_libraries(eccecipc PUBLIC OpenSSL::SSL OpenSSL::Crypto)
229231
ecce_library(eccedav src/dsm/dav)
230232
ecce_library(eccedsi src/dsm/edsiimpl)
231233
ecce_library(eccefaces src/dsm/faces)
@@ -1350,9 +1352,12 @@ set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
13501352
# perl: the whole job pipeline (ai.<code>, *.expt, gensub,
13511353
# eccejobmonitor, processmachine) is Perl, and Getopt::Std and
13521354
# Sys::Hostname are in perl-modules, which perl-base alone lacks.
1355+
# openssl: ecce-remote-setup --tls makes and checks certificates with the
1356+
# command line tool (#236); the libraries come from shlibdeps. Apache's
1357+
# mod_ssl is in the apache2 package on Debian but its own package on RPM.
13531358
# xterm: editing an input file with the default editor (vi) opens it in
13541359
# an xterm (UserEditor.C).
1355-
set(CPACK_DEBIAN_PACKAGE_DEPENDS "apache2, apache2-utils, python3-wxgtk4.0, mosquitto, libaprutil1, perl, xterm")
1360+
set(CPACK_DEBIAN_PACKAGE_DEPENDS "apache2, apache2-utils, python3-wxgtk4.0, mosquitto, libaprutil1, perl, xterm, openssl")
13561361
# NWChem is a real Debian package (confirmed working: /usr/bin/nwchem,
13571362
# symlinked to nwchem.openmpi by the package itself) and the auto-register-
13581363
# this-machine flow (RunMgmt::registerLocalMachine()) defaults new machine
@@ -1407,7 +1412,7 @@ if(RPMBUILD_EXECUTABLE)
14071412
set(CPACK_RPM_PACKAGE_LICENSE "ECL-2.0")
14081413
set(CPACK_RPM_PACKAGE_GROUP "Applications/Engineering")
14091414
set(CPACK_RPM_PACKAGE_URL "https://github.com/FriendsofECCE/ECCE")
1410-
set(CPACK_RPM_PACKAGE_REQUIRES "httpd, httpd-tools, python3-wxpython4, perl, xterm, mosquitto, apr-util${_coin_rpm}")
1415+
set(CPACK_RPM_PACKAGE_REQUIRES "httpd, httpd-tools, mod_ssl, openssl, python3-wxpython4, perl, xterm, mosquitto, apr-util${_coin_rpm}")
14111416
set(CPACK_RPM_PACKAGE_SUGGESTS "nwchem, openssh-clients, firefox")
14121417
set(CPACK_RPM_FILE_NAME RPM-DEFAULT)
14131418
# /opt/ecce is ours, but /opt itself is owned by filesystem(5). Without
@@ -1551,7 +1556,7 @@ if(ECCE_SPLIT_PACKAGES)
15511556
# -remote client only needs the library, and the package's own system
15521557
# service is of no use to it.
15531558
set(CPACK_DEBIAN_CLIENT_PACKAGE_DEPENDS
1554-
"python3-wxgtk4.0, perl, xterm, libmosquitto1, curl")
1559+
"python3-wxgtk4.0, perl, xterm, libmosquitto1, curl, openssl")
15551560
# A client on the SAME machine as a server benefits from it (the
15561561
# default "everything on one box" case this project still ships), but
15571562
# a client of an already-central server (#167/#138 -- the teaching
@@ -1574,7 +1579,7 @@ if(ECCE_SPLIT_PACKAGES)
15741579
# (launchjob, eccejobmaster, eccejobstore) ship in ecce-client, and a server
15751580
# needs them, so the server brings the client -- GUI libraries included,
15761581
# unused on a headless box. Accepted rather than a third package.
1577-
set(CPACK_DEBIAN_SERVER_PACKAGE_DEPENDS "apache2, apache2-utils, mosquitto, libaprutil1, ecce-client (= ${_ecce_deb_version})")
1582+
set(CPACK_DEBIAN_SERVER_PACKAGE_DEPENDS "apache2, apache2-utils, mosquitto, libaprutil1, openssl, ecce-client (= ${_ecce_deb_version})")
15781583
set(CPACK_DEBIAN_SERVER_PACKAGE_BREAKS "ecce (<< ${ECCE_MONOLITHIC_BREAK_VERSION})")
15791584
set(CPACK_DEBIAN_SERVER_PACKAGE_REPLACES "ecce (<< ${ECCE_MONOLITHIC_BREAK_VERSION})")
15801585

@@ -1587,7 +1592,7 @@ if(ECCE_SPLIT_PACKAGES)
15871592
set(CPACK_RPM_CLIENT_FILE_NAME RPM-DEFAULT)
15881593
# mosquitto provides both the library and the broker on RPM distros.
15891594
set(CPACK_RPM_CLIENT_PACKAGE_REQUIRES
1590-
"python3-wxpython4, perl, xterm, mosquitto, curl${_coin_rpm}")
1595+
"python3-wxpython4, perl, xterm, mosquitto, curl, openssl${_coin_rpm}")
15911596
set(CPACK_RPM_CLIENT_PACKAGE_SUGGESTS
15921597
"ecce-server, nwchem, openssh-clients, firefox")
15931598
# RPM has no direct Breaks/Replaces pair; Obsoletes+Provides is the
@@ -1600,7 +1605,7 @@ if(ECCE_SPLIT_PACKAGES)
16001605
set(CPACK_RPM_CLIENT_PACKAGE_CONFLICTS "ecce < ${ECCE_MONOLITHIC_BREAK_VERSION}")
16011606
set(CPACK_RPM_SERVER_PACKAGE_NAME "ecce-server")
16021607
set(CPACK_RPM_SERVER_FILE_NAME RPM-DEFAULT)
1603-
set(CPACK_RPM_SERVER_PACKAGE_REQUIRES "httpd, httpd-tools, mosquitto, apr-util, ecce-client = ${_ecce_deb_version}")
1608+
set(CPACK_RPM_SERVER_PACKAGE_REQUIRES "httpd, httpd-tools, mod_ssl, openssl, mosquitto, apr-util, ecce-client = ${_ecce_deb_version}")
16041609
set(CPACK_RPM_SERVER_PACKAGE_OBSOLETES "ecce < ${ECCE_MONOLITHIC_BREAK_VERSION}")
16051610
set(CPACK_RPM_SERVER_PACKAGE_CONFLICTS "ecce < ${ECCE_MONOLITHIC_BREAK_VERSION}")
16061611
set(CPACK_RPM_EXCLUDE_FROM_AUTO_FILELIST_ADDITION "/opt")
@@ -1682,6 +1687,30 @@ if(TARGET ecce_users_auth)
16821687
set_tests_properties(mqtt-auth-plugin PROPERTIES TIMEOUT 120 SKIP_RETURN_CODE 77)
16831688
endif()
16841689
set_tests_properties(mqtt mqtt-auth PROPERTIES TIMEOUT 120 SKIP_RETURN_CODE 77)
1690+
1691+
# tests/tls -- https data-server client (#236): pinned certificate, CA mode
1692+
# with host-name check, certificate rejection, no fallback to plain http.
1693+
# Needs python3 and the openssl command line; SKIPs (77) without them.
1694+
add_executable(tls_client_test tests/tls/tls_client_test.C)
1695+
target_include_directories(tls_client_test PRIVATE ${XercesC_INCLUDE_DIRS})
1696+
target_link_libraries(tls_client_test PRIVATE
1697+
eccedsi eccedav eccecipc eccefaces eccexml eccetdat ecceutil ${XercesC_LIBRARIES})
1698+
add_test(NAME tls_client
1699+
COMMAND ${PYTHON3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/tls/run_tests.py
1700+
$<TARGET_FILE:tls_client_test> ${CMAKE_BINARY_DIR}/tls-test)
1701+
set_tests_properties(tls_client PROPERTIES TIMEOUT 120 SKIP_RETURN_CODE 77)
1702+
1703+
# The central server over TLS (#236): the real data server and broker scripts
1704+
# on loopback with a fresh certificate, then the clients with and without
1705+
# the right pin.
1706+
add_executable(tls_broker_test tests/tls/tls_broker_test.C)
1707+
target_include_directories(tls_broker_test PRIVATE ${XercesC_INCLUDE_DIRS})
1708+
target_link_libraries(tls_broker_test PRIVATE ecceutil ${XercesC_LIBRARIES})
1709+
add_test(NAME tls_server
1710+
COMMAND ${PYTHON3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/tls/server_tls.py
1711+
$<TARGET_FILE:tls_client_test> $<TARGET_FILE:tls_broker_test>
1712+
${CMAKE_BINARY_DIR} ${CMAKE_BINARY_DIR}/tls-server-test)
1713+
set_tests_properties(tls_server PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
16851714
if(PYTHON3_EXECUTABLE)
16861715
add_test(NAME parsers
16871716
COMMAND ${PYTHON3_EXECUTABLE}

‎GETTING_STARTED.md‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -407,10 +407,13 @@ On a TCP broker (modes 2 and 3) no anonymous client is accepted, and an
407407
account may publish and subscribe only below `ecce/<its name>/` (plus
408408
hearing that a machine registration changed). One user therefore cannot
409409
act in another's name, for example by cancelling that user's jobs. The
410-
rules are `server/mosquitto.acl`. The data server still speaks plain
411-
HTTP and the broker's password is sent unencrypted too (#138): keep both
412-
ports on a trusted network or firewall them. The accounts keep users
413-
apart; they are not a defence against an untrusted network.
410+
rules are `server/mosquitto.acl`. Unless TLS is switched on (mode 2,
411+
below) the data server speaks plain HTTP and the broker's password is
412+
sent unencrypted too (#138): keep both ports on a trusted network or
413+
firewall them. The accounts keep users apart; they are not a defence
414+
against an untrusted network, and neither is TLS here: it keeps
415+
passwords and data off the wire, and is no security product.
416+
Mode 3 has no TLS yet.
414417

415418
A client stopping never stops a central or shared broker. Which broker a
416419
quit may stop is decided only by what the admin declared, never by
@@ -483,6 +486,43 @@ client after the admin changes that list (#188). It writes only the data server'
483486
client finds the broker on the same host, port 8088 (set `ECCE_BROKER_PORT`
484487
in the client's environment if the server uses another).
485488

489+
##### TLS for the central server (optional)
490+
491+
Off by default. On the server, as the account that runs it:
492+
493+
```
494+
ecce-remote-setup --server all --tls # makes a 10-year self-signed certificate
495+
ecce-dataserver-stop; ecce-gateway-stop; ecce-dataserver-start && ecce-gateway-start
496+
```
497+
498+
The data server then serves HTTPS on port 8443
499+
(`ECCE_DATASERVER_TLS_PORT`) and the broker TLS on port 8883
500+
(`ECCE_BROKER_TLS_PORT`). The plain ports (8096, 8088) accept connections
501+
from the server machine itself only, so open 8443 and 8883 in the
502+
firewall, not those. The certificate and key are in `~/.ECCE/tls/`
503+
(`--new-cert` replaces them, `--cert F --key F` installs your own). Give
504+
every client installation `~/.ECCE/tls/server.pem`, and on each client, as
505+
root:
506+
507+
```
508+
sudo ecce-remote-setup <server-host> --tls --pin server.pem
509+
```
510+
511+
That client then accepts only this exact certificate and refuses any
512+
other; it never falls back to plain HTTP or an unencrypted broker, and
513+
reports a certificate that does not match. The installed copy is
514+
`/opt/ecce/siteconfig/RemoteServer/server.pem`; a per-user override does
515+
not exist. `--fetch-pin` takes the certificate from the running server
516+
instead and prints its fingerprint to compare with the one the server's
517+
administrator reads out (`ecce-remote-setup --server` shows it).
518+
519+
A certificate from Let's Encrypt or the university's CA works too, but is
520+
only documented here: pass its full chain and key as `--cert` and `--key`
521+
on the server (renewed certificates need the same command and a restart),
522+
and set the clients up with `--tls --system-ca`, which checks the host
523+
name against the system's CA list and installs no pin. Reach the server by
524+
the name in the certificate.
525+
486526
##### Changing the site settings from a client
487527

488528
An administrator can edit the server's site machine list from a client

‎data/client/config/EDSI‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ UNKNOWN: %s unrecognized error.
1111
# Client side status codes
1212
CLIENT_ERROR: The application is unable to access the data server
1313
SERVER_NOT_FOUND: Server or server port is not available. %s
14+
CERTIFICATE_REJECTED: The data server's certificate was rejected. Contact the ECCE administrator. %s
1415

1516
# Different implementations of AuthEventListener may choose to abort
1617
# after a certain number of authentication attempts.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
type: rule
3+
title: "The data-server client speaks TLS only when the URL says https, and never falls back"
4+
area: services
5+
section: "Pitfalls"
6+
paths: ["src/dsm/cipc/CTLSSocket.C", "src/dsm/cipc/CHTTPConnection.C", "src/dsm/dav/EcceDAVClient.C", "tests/tls/run_tests.py"]
7+
issues: [236]
8+
---
9+
**`https://` selects `ipc::CTLSClientSocket` (OpenSSL); `http://` is the
10+
unchanged plain socket. There is no fallback from one to the other.**
11+
- Trust: if `$ECCE_HOME/siteconfig/RemoteServer/server.pem` exists it is the
12+
only trust anchor and the presented certificate must be identical to it
13+
(host name unchecked). A pin file that exists but cannot be read is an
14+
error, not a reason to use the system store. Without the file the system
15+
store and host-name verification apply (CA mode).
16+
- A rejected certificate gives `EcceDAVStatus::CERTIFICATE_REJECTED` (EDSI
17+
message `CERTIFICATE_REJECTED`); other TLS failures are `UNABLE_TO_CONNECT`.
18+
- `poll()` must check `SSL_pending()` first, and a readable fd is confirmed
19+
by a non-blocking `SSL_peek` (TLS 1.3 tickets make the fd readable with no
20+
application data).
21+
- The TLS socket cannot be copied; a copied `CHTTPConnection` that held a TLS
22+
session starts disconnected and re-verifies on `connect()`.
23+
- https defaults to port 443, and the `Host` header omits the port only for
24+
the scheme's default.
25+
- `ctest -R tls_client` covers pinned, CA, mismatch and no-fallback cases.

‎docs/claude/services/index.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ what the deployment mode needs (GETTING_STARTED, "Deployment modes"):
4040

4141
- [TCP broker accounts are the data server logins](broker-accounts-are-the-data-server-logins.md)
4242
- [`ecce_auth_changed` carries no password](auth-changed-carries-no-password.md)
43+
- [The data-server client speaks TLS only for https URLs, never falls back](https-data-server-client.md)
44+
- [Central-server TLS: ports, loopback, what must stay in step](tls-central-server.md)
4345
- [Per-session state is keyed by the session id, and the "is it already running?" checks must be too](per-user-service-state-is-keyed-by.md)
4446
- [A calculation can live in any local folder, not only in a data store](calculations-in-any-local-folder.md)
4547
- [Waiting for login: a lost monitor parks the job, session start catches it up](waiting-for-login.md)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
type: rule
3+
title: "Central-server TLS: ports, who is on loopback, and what must stay in step"
4+
area: services
5+
section: "Pitfalls"
6+
paths: ["packaging/dataserver/ecce-remote-setup", "packaging/dataserver/ecce-dataserver-start", "packaging/dataserver/httpd.conf.ecce", "packaging/gateway/ecce-gateway-start", "src/util/jms/MqttLink.C", "tests/tls/server_tls.py"]
7+
issues: [236]
8+
---
9+
**TLS is opt-in per server (`ecce-remote-setup --server --tls`) and per client
10+
(`--tls --pin|--fetch-pin|--system-ca`); nothing detects it and nothing falls back.**
11+
- Server state is `~/.ECCE/tls/{server.pem,server.key,enabled}`, shared by the
12+
data server and the broker (not under `~/.ECCE/dataserver`, which is
13+
Apache's ServerRoot). `enabled` is what both start scripts read.
14+
- With TLS on, the plain ports (8096, 8088) are bound to 127.0.0.1/::1 only
15+
and the TLS ports (8443, 8883) go on the listen setting's addresses *and*
16+
loopback. A wildcard `Listen 8443` cannot be combined with
17+
`127.0.0.1:8443` (address in use), so `all` gets the wildcard alone.
18+
- The client learns TLS from the `https://` URL in `RemoteServer/DataServers`;
19+
`ecce-gateway-start` takes the broker's port (8883) and the
20+
`tls=1`/`cafile=`/`pinned=1` keys of the broker file from it. The pin is
21+
`siteconfig/RemoteServer/server.pem`; without it the system CA directory
22+
and host-name check apply.
23+
- Pinned mode checks the chain against the one certificate and ignores the
24+
host name (`mosquitto_tls_insecure_set`, curl `--insecure
25+
--pinnedpubkey`): never use curl `--insecure` without the pin.
26+
- A failed handshake reaches `MqttLink` only as a libmosquitto log line
27+
(`onLog`), not a CONNACK; the refusal text starts with `TLS:`.
28+
- `ecce-remote-setup --tls` probes the server and writes nothing if the
29+
certificate is not trusted (curl exit 90/60/35); only an unreachable
30+
server (7/28) is a warning.
31+
- Mode 3 (`siteconfig/SharedBroker`) has no TLS.
32+
- `ctest -R tls_server` runs the whole thing on loopback; it needs
33+
mod_ssl and, for the network-address checks, a non-loopback IPv4 address.

‎include/dsm/CHTTPConnection.H‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ public :
4545
virtual bool connect(
4646
const string_type& host,
4747
port_type port = HTTPDefPort,
48-
const string_type& proxy = "");
48+
const string_type& proxy = "",
49+
bool secure = false); // true: TLS (https), never plain
50+
51+
// After a failed connect(): was the server certificate rejected?
52+
bool certificateRejected(void) const { return certRejected_; }
53+
const string_type& connectError(void) const { return connectError_; }
4954

5055
// Did the connection to the server die?
5156
virtual bool deadConnection(void) const
@@ -128,6 +133,9 @@ protected :
128133
proxy_; // proxy server ("" if no proxy)
129134
port_type port_; // server port
130135
socket_type *server_; // client socket to server
136+
bool secure_; // TLS (https)
137+
bool certRejected_; // last connect() failed on certificate
138+
string_type connectError_; // last connect() failure text
131139

132140
private :
133141

‎include/dsm/CTLSSocket.H‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// CTLSSocket.H -- TLS client socket (OpenSSL) for the https data server.
2+
//
3+
// Trust: if $ECCE_HOME/siteconfig/RemoteServer/server.pem exists, that
4+
// certificate is the only trust anchor and the presented certificate must be
5+
// identical to it (host name is not checked, so IP addresses and aliases
6+
// work). Otherwise the system store is used and the host name is verified.
7+
// There is no mode in which verification is skipped.
8+
9+
#if !defined( __CTLSSOCKET_H )
10+
#define __CTLSSOCKET_H
11+
12+
#include <string>
13+
#include "dsm/CSocket.H"
14+
15+
typedef struct ssl_ctx_st SSL_CTX;
16+
typedef struct ssl_st SSL;
17+
18+
namespace ipc {
19+
20+
class CTLSError : public CSocketError
21+
{
22+
public :
23+
// certificate = true when the server's certificate was rejected
24+
CTLSError(const std::string& msg, bool certificate)
25+
: CSocketError(CreateError), msg_(msg), cert_(certificate) {}
26+
virtual ~CTLSError(void) {}
27+
28+
bool certificateRejected(void) const { return cert_; }
29+
virtual string_type message(void) const { return msg_; }
30+
31+
private :
32+
std::string msg_;
33+
bool cert_;
34+
};
35+
36+
class CTLSClientSocket : public CClientSocket
37+
{
38+
public :
39+
40+
// Connect, handshake and verify. Throws CSocketError (TCP) or CTLSError.
41+
CTLSClientSocket(const string_type& host, port_type port);
42+
43+
// The SSL session belongs to exactly one socket.
44+
CTLSClientSocket(const CTLSClientSocket&) = delete;
45+
CTLSClientSocket& operator = (const CTLSClientSocket&) = delete;
46+
47+
virtual ~CTLSClientSocket(void);
48+
49+
virtual bool poll(size_type seconds = 0, size_type microseconds = 0);
50+
virtual size_type receive(void * buff, size_type nbytes, int flags = 0);
51+
virtual size_type send(const string_type&);
52+
virtual size_type send(const void * buff, size_type nbytes);
53+
54+
// Path of the pinned certificate, or "" when the system store applies.
55+
static string_type pinnedCertPath(void);
56+
57+
private :
58+
SSL_CTX * ctx_;
59+
SSL * ssl_;
60+
};
61+
62+
} // namespace ipc
63+
64+
#endif

‎include/dsm/EcceDAVStatus.H‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ public:
7676
UNABLE_TO_COMPLETE_REQUEST,
7777
UNABLE_TO_SEND_REQUEST,
7878
UNABLE_TO_GET_RESPONSE,
79-
INTERRUPTED
79+
INTERRUPTED,
80+
CERTIFICATE_REJECTED // https: server certificate failed verification
8081
};
8182

8283
static string text(int key);

‎include/util/MqttLink.H‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ struct MqttConfig {
5757
std::string socket; // Unix-socket broker, or
5858
std::string host; // host + port
5959
int port = 1883;
60+
bool tls = false; // tls=1: the broker speaks TLS on host:port
61+
std::string cafile; // trust anchor: the pinned server certificate, or
62+
std::string capath; // the system's CA directory (no pin)
63+
bool pinned = false; // pinned=1: the exact certificate is the anchor
6064
std::string user; // topic user
6165
std::string sessionKey; // Ecce::sessionKey(): <host>_<session id>
6266

@@ -168,6 +172,7 @@ private:
168172
~MqttLink();
169173
static void onConnect(mosquitto*, void*, int rc, int flags,
170174
const mqtt5__property*);
175+
static void onLog(mosquitto*, void*, int level, const char* str);
171176
static void onDisconnect(mosquitto*, void*, int rc, const mqtt5__property*);
172177
static void onMessage(mosquitto*, void*, const mosquitto_message*,
173178
const mqtt5__property*);

0 commit comments

Comments
 (0)