Skip to content

Commit 543d2bd

Browse files
authored
Merge c22aeb2 into 254d15e
2 parents 254d15e + c22aeb2 commit 543d2bd

File tree

7 files changed

+440
-56
lines changed

7 files changed

+440
-56
lines changed

NEWS.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ https://github.com/networkupstools/nut/milestone/12
122122
* Revised OpenSSL handshake to support retries requested by the library,
123123
so it at least works reliably on different platforms within the scope
124124
it has now. [issue #3331, PR #3344]
125+
* Extended `PyNUTClient` to support `STARTTLS` and optional server/self
126+
identification, added NIT tests for that ability. [issues #1711, #3329,
127+
#1600, PR #3352].
125128

126129
- NUT for Windows specific updates:
127130
* Revised detection of (relative) paths to program and configuration files

clients/upsclient.c

Lines changed: 120 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -204,16 +204,16 @@ static int ssl_error(SSL *ssl, ssize_t ret)
204204
switch (e)
205205
{
206206
case SSL_ERROR_WANT_READ:
207-
upslogx(LOG_ERR, "ssl_error() ret=%" PRIiSIZE " SSL_ERROR_WANT_READ", ret);
208-
break;
207+
upsdebugx(4, "ssl_error() ret=%" PRIiSIZE " SSL_ERROR_WANT_READ", ret);
208+
return 0;
209209

210210
case SSL_ERROR_WANT_WRITE:
211-
upslogx(LOG_ERR, "ssl_error() ret=%" PRIiSIZE " SSL_ERROR_WANT_WRITE", ret);
212-
break;
211+
upsdebugx(4, "ssl_error() ret=%" PRIiSIZE " SSL_ERROR_WANT_WRITE", ret);
212+
return 0;
213213

214214
case SSL_ERROR_SYSCALL:
215215
if (ret == 0 && ERR_peek_error() == 0) {
216-
upslogx(LOG_ERR, "ssl_error() EOF from client");
216+
upslogx(LOG_ERR, "ssl_error() EOF from server");
217217
} else {
218218
upslogx(LOG_ERR, "ssl_error() ret=%" PRIiSIZE " SSL_ERROR_SYSCALL", ret);
219219
}
@@ -709,16 +709,68 @@ static ssize_t net_read(UPSCONN_t *ups, char *buf, size_t buflen, const time_t t
709709
#ifdef WITH_SSL
710710
if (ups->ssl) {
711711
# ifdef WITH_OPENSSL
712+
int iret, ssl_err, ssl_retries = 0;
713+
/* Cap retries to avoid spinning forever on a broken socket.
714+
* 250 * 20 ms = 5 s maximum wait, which is generous for a
715+
* local handshake while being safe for CI timeouts.
716+
*/
717+
const int SSL_IO_MAX_RETRIES = 250;
718+
fd_set fds;
719+
struct timeval tv;
720+
712721
/* SSL_* routines deal with int type for return and buflen
713722
* We might need to window our I/O if we exceed 2GB (in
714723
* 32-bit builds)... Not likely to exceed in 64-bit builds,
715724
* but smaller systems with 16-bits might be endangered :)
716725
*/
717-
int iret;
718726
assert(buflen <= INT_MAX);
719-
iret = SSL_read(ups->ssl, buf, (int)buflen);
720-
assert(iret <= SSIZE_MAX);
721-
ret = (ssize_t)iret;
727+
728+
while (ssl_retries < SSL_IO_MAX_RETRIES) {
729+
iret = SSL_read(ups->ssl, buf, (int)buflen);
730+
731+
assert(iret <= SSIZE_MAX);
732+
if (iret > 0) {
733+
ret = (ssize_t)iret;
734+
break;
735+
}
736+
737+
if (iret == 0) {
738+
/* Orderly shutdown or actual EOF */
739+
ret = 0;
740+
break;
741+
}
742+
743+
ssl_err = SSL_get_error(ups->ssl, iret);
744+
if (ssl_err == SSL_ERROR_WANT_READ
745+
|| ssl_err == SSL_ERROR_WANT_WRITE
746+
) {
747+
FD_ZERO(&fds);
748+
FD_SET(ups->fd, &fds);
749+
tv.tv_sec = 0;
750+
tv.tv_usec = 20000; /* 20 ms */
751+
752+
if (select(ups->fd + 1,
753+
(ssl_err == SSL_ERROR_WANT_READ) ? &fds : NULL,
754+
(ssl_err == SSL_ERROR_WANT_WRITE) ? &fds : NULL,
755+
NULL, &tv) < 0
756+
) {
757+
/* select failure is fatal enough to stop retrying */
758+
ssl_error(ups->ssl, (ssize_t)iret);
759+
return -1;
760+
}
761+
ssl_retries++;
762+
continue;
763+
}
764+
765+
/* Other errors are fatal */
766+
ssl_error(ups->ssl, (ssize_t)iret);
767+
return -1;
768+
}
769+
770+
if (ssl_retries >= SSL_IO_MAX_RETRIES) {
771+
upslogx(LOG_ERR, "%s: SSL_read timed out after %d retries", __func__, ssl_retries);
772+
return -1;
773+
}
722774
# elif defined(WITH_NSS) /* WITH_OPENSSL */
723775
/* PR_* routines deal in PRInt32 type
724776
* We might need to window our I/O if we exceed 2GB :) */
@@ -794,16 +846,62 @@ static ssize_t net_write(UPSCONN_t *ups, const char *buf, size_t buflen, const t
794846
#ifdef WITH_SSL
795847
if (ups->ssl) {
796848
# ifdef WITH_OPENSSL
849+
int iret, ssl_err, ssl_retries = 0;
850+
/* Cap retries to avoid spinning forever on a broken socket.
851+
* 250 * 20 ms = 5 s maximum wait, which is generous for a
852+
* local handshake while being safe for CI timeouts.
853+
*/
854+
const int SSL_IO_MAX_RETRIES = 250;
855+
fd_set fds;
856+
struct timeval tv;
857+
797858
/* SSL_* routines deal with int type for return and buflen
798859
* We might need to window our I/O if we exceed 2GB (in
799860
* 32-bit builds)... Not likely to exceed in 64-bit builds,
800861
* but smaller systems with 16-bits might be endangered :)
801862
*/
802-
int iret;
803863
assert(buflen <= INT_MAX);
804-
iret = SSL_write(ups->ssl, buf, (int)buflen);
805-
assert(iret <= SSIZE_MAX);
806-
ret = (ssize_t)iret;
864+
865+
while (ssl_retries < SSL_IO_MAX_RETRIES) {
866+
iret = SSL_write(ups->ssl, buf, (int)buflen);
867+
868+
assert(iret <= SSIZE_MAX);
869+
if (iret > 0) {
870+
ret = (ssize_t)iret;
871+
break;
872+
}
873+
874+
ssl_err = SSL_get_error(ups->ssl, iret);
875+
if (ssl_err == SSL_ERROR_WANT_READ
876+
|| ssl_err == SSL_ERROR_WANT_WRITE
877+
) {
878+
FD_ZERO(&fds);
879+
FD_SET(ups->fd, &fds);
880+
tv.tv_sec = 0;
881+
tv.tv_usec = 20000; /* 20 ms */
882+
883+
if (select(ups->fd + 1,
884+
(ssl_err == SSL_ERROR_WANT_READ) ? &fds : NULL,
885+
(ssl_err == SSL_ERROR_WANT_WRITE) ? &fds : NULL,
886+
NULL, &tv) < 0
887+
) {
888+
/* select failure is fatal enough to stop retrying */
889+
ssl_error(ups->ssl, (ssize_t)iret);
890+
return -1;
891+
}
892+
ssl_retries++;
893+
continue;
894+
}
895+
896+
/* Other errors (including iret=0) are fatal */
897+
ssl_error(ups->ssl, (ssize_t)iret);
898+
return -1;
899+
}
900+
901+
if (ssl_retries >= SSL_IO_MAX_RETRIES) {
902+
upslogx(LOG_ERR, "%s: SSL_write timed out after %d retries", __func__, ssl_retries);
903+
return -1;
904+
}
807905
# elif defined(WITH_NSS) /* WITH_OPENSSL */
808906
/* PR_* routines deal in PRInt32 type
809907
* We might need to window our I/O if we exceed 2GB :) */
@@ -938,13 +1036,14 @@ static int upscli_sslinit(UPSCONN_t *ups, int verifycert)
9381036
int ssl_retries = 0;
9391037
/* Cap retries to avoid spinning forever on a broken socket.
9401038
* 250 * 20 ms = 5 s maximum wait, which is generous for a
941-
* local handshake while being safe for CI timeouts. */
942-
const int SSL_CONNECT_MAX_RETRIES = 250;
1039+
* local handshake while being safe for CI timeouts.
1040+
*/
1041+
const int SSL_IO_MAX_RETRIES = 250;
9431042
fd_set fds;
9441043
struct timeval tv;
9451044

9461045
res = -1;
947-
while (ssl_retries < SSL_CONNECT_MAX_RETRIES) {
1046+
while (ssl_retries < SSL_IO_MAX_RETRIES) {
9481047
res = SSL_connect(ups->ssl);
9491048

9501049
if (res == 1) {
@@ -972,7 +1071,7 @@ static int upscli_sslinit(UPSCONN_t *ups, int verifycert)
9721071
(ssl_err == SSL_ERROR_WANT_READ)
9731072
? "READ" : "WRITE",
9741073
ssl_retries + 1,
975-
SSL_CONNECT_MAX_RETRIES);
1074+
SSL_IO_MAX_RETRIES);
9761075

9771076
if (select(ups->fd + 1,
9781077
(ssl_err == SSL_ERROR_WANT_READ) ? &fds : NULL,
@@ -982,6 +1081,9 @@ static int upscli_sslinit(UPSCONN_t *ups, int verifycert)
9821081
upsdebug_with_errno(1,
9831082
"%s: select() failed during SSL_connect",
9841083
__func__);
1084+
/* Returns 0 on non-fatal WANT_READ/WRITE;
1085+
* we stop retrying even if non-fatal because
1086+
* select() itself failed. */
9851087
ssl_error(ups->ssl, res);
9861088
return -1;
9871089
}
@@ -1005,7 +1107,7 @@ static int upscli_sslinit(UPSCONN_t *ups, int verifycert)
10051107
return -1;
10061108
}
10071109

1008-
if (ssl_retries >= SSL_CONNECT_MAX_RETRIES) {
1110+
if (ssl_retries >= SSL_IO_MAX_RETRIES) {
10091111
upslogx(LOG_ERR,
10101112
"%s: SSL_connect timed out after %d retries"
10111113
" (non-blocking handshake never completed)",

scripts/python/module/PyNUT.py.in

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# -*- coding: utf-8 -*-
33

44
# Copyright (C) 2008 David Goncalves <david@lestat.st>
5+
# Copyright (C) 2021-2026 Jim Klimov <jimklimov+nut@gmail.com>
56
#
67
# This program is free software: you can redistribute it and/or modify
78
# it under the terms of the GNU General Public License as published by
@@ -63,51 +64,102 @@
6364
#
6465
# 2025-01-31 cgar <github.com/cgarz> - Version 1.8.0
6566
# Removed telnetlib dependency. Switched to using socket directly.
67+
#
68+
# 2026-03-16 Jim Klimov <jimklimov+nut@gmail.com> - Version 1.9.0
69+
# Implement selectable OpenSSL and NSS client support via
70+
# NUT STARTTLS protocol action.
6671

6772
import socket
6873

74+
ssl_available = False
75+
try:
76+
import ssl
77+
ssl_available = True
78+
except:
79+
pass
80+
6981
class PyNUTError( Exception ) :
7082
""" Base class for custom exceptions """
7183

7284

7385
class PyNUTClient :
7486
""" Abstraction class to access NUT (Network UPS Tools) server """
7587

76-
__debug = None # Set class to debug mode (prints everything useful for debuging...)
88+
__debug = None # Set class to debug mode (prints everything useful for debugging...)
7789
__host = None
7890
__port = None
7991
__login = None
8092
__password = None
8193
__timeout = None
8294
__srv_handler = None
8395
__recv_leftover = b''
96+
# Subject to global ssl_available:
97+
__use_ssl = False
98+
__ssl_context = None
8499

85-
__version = "1.8.0"
86-
__release = "2025-02-07"
100+
__version = "1.9.0"
101+
__release = "2026-03-16"
87102

88103

89-
def __init__( self, host="127.0.0.1", port=3493, login=None, password=None, debug=False, timeout=5 ) :
104+
def __init__( self, host="127.0.0.1", port=3493, login=None, password=None, debug=False, timeout=5,
105+
use_ssl=False, ssl_context=None, cert_verify=None, ca_file=None, ca_path=None,
106+
cert_file=None, key_file=None, force_ssl=False ) :
90107
""" Class initialization method
91108

92-
host : Host to connect (default to localhost)
93-
port : Port where NUT listens for connections (default to 3493)
94-
login : Login used to connect to NUT server (default to None for no authentication)
95-
password : Password used when using authentication (default to None)
96-
debug : Boolean, put class in debug mode (prints everything on console, default to False)
97-
timeout : Timeout used to wait for network response
109+
host : Host to connect (default to localhost)
110+
port : Port where NUT listens for connections (default to 3493)
111+
login : Login used to connect to NUT server (default to None for no authentication)
112+
password : Password used when using authentication (default to None)
113+
debug : Boolean, put class in debug mode (prints everything on console, default to False)
114+
timeout : Timeout used to wait for network response
115+
use_ssl : Boolean, use SSL/TLS for connection (default to False; subject to 'ssl' module availability)
116+
ssl_context : ssl.SSLContext object to use for SSL/TLS connection (default to None; subject to 'ssl' module availability)
117+
cert_verify : Boolean, verify server certificate (default to None, which means True if CA info is provided)
118+
ca_file : Path to CA certificate file (PEM format)
119+
ca_path : Path to directory with CA certificates (PEM format)
120+
cert_file : Path to client certificate file (PEM format)
121+
key_file : Path to client private key file (PEM format)
122+
force_ssl : Boolean, if True, the connection must be secure (default to False)
98123
"""
99124
self.__debug = debug
100125

101126
if self.__debug :
102127
print( "[DEBUG] Class initialization..." )
103128
print( "[DEBUG] -> Host = %s (port %s)" % ( host, port ) )
104129
print( "[DEBUG] -> Login = '%s' / '%s'" % ( login, password ) )
105-
106-
self.__host = host
107-
self.__port = port
108-
self.__login = login
109-
self.__password = password
110-
self.__timeout = timeout
130+
print( "[DEBUG] -> SSL = %s (force: %s, verify: %s)" % (use_ssl, force_ssl, cert_verify) )
131+
132+
self.__host = host
133+
self.__port = port
134+
self.__login = login
135+
self.__password = password
136+
self.__timeout = timeout
137+
if ssl_available:
138+
self.__use_ssl = use_ssl or force_ssl
139+
self.__force_ssl = force_ssl
140+
self.__ssl_context = ssl_context
141+
if self.__use_ssl and self.__ssl_context is None:
142+
if self.__debug:
143+
print( "[DEBUG] Creating SSL context" )
144+
# Create a default context and apply parameters
145+
self.__ssl_context = ssl.create_default_context(cafile=ca_file, capath=ca_path)
146+
if cert_verify is not None:
147+
if not cert_verify:
148+
# Must be set first, else we get
149+
# ValueError: Cannot set verify_mode to CERT_NONE when check_hostname is enabled.
150+
self.__ssl_context.check_hostname = False
151+
self.__ssl_context.verify_mode = ssl.CERT_REQUIRED if cert_verify else ssl.CERT_NONE
152+
if cert_file:
153+
self.__ssl_context.load_cert_chain(certfile=cert_file, keyfile=key_file)
154+
else:
155+
self.__use_ssl = False
156+
self.__force_ssl = False
157+
self.__ssl_context = None
158+
if (use_ssl or force_ssl or ssl_context is not None):
159+
if self.__debug:
160+
print( "[DEBUG] SSL requested but 'ssl' module was not available for import" )
161+
if force_ssl:
162+
raise PyNUTError( "SSL required but 'ssl' module not available" )
111163

112164
self.__connect()
113165

@@ -150,6 +202,30 @@ if something goes wrong.
150202
self.__timeout
151203
)
152204

205+
if self.__use_ssl :
206+
if self.__debug :
207+
print( "[DEBUG] Requesting STARTTLS" )
208+
self.__srv_handler.send( b"STARTTLS\n" )
209+
result = self.__read_until( b"\n" )
210+
if result[:11] == b"OK STARTTLS" :
211+
if self.__debug :
212+
print( "[DEBUG] STARTTLS accepted, wrapping socket" )
213+
if self.__ssl_context is None :
214+
self.__ssl_context = ssl.create_default_context()
215+
self.__srv_handler = self.__ssl_context.wrap_socket(
216+
self.__srv_handler,
217+
server_hostname=self.__host
218+
)
219+
else :
220+
if self.__debug :
221+
print( "[DEBUG] STARTTLS failed: %s" % result )
222+
if self.__force_ssl:
223+
raise PyNUTError( "STARTTLS failed but SSL is required: %s" % result.replace( b"\n", b"" ).decode('ascii') )
224+
else:
225+
if self.__debug:
226+
print( "[DEBUG] STARTTLS failed, but not forced, continuing insecurely" )
227+
self.__use_ssl = False
228+
153229
if self.__login != None :
154230
self.__srv_handler.send( ("USERNAME %s\n" % self.__login).encode('ascii') )
155231
result = self.__read_until( b"\n" )

0 commit comments

Comments
 (0)