Skip to content

Commit 91ed3fa

Browse files
authored
Feat: Server over https (#1487)
* Update run_server.py * Update __init__.py * Update run_server.py * Update __init__.py * Update __init__.py * Update run_server.py * Update README.md * Update __init__.py * Update __init__.py * Update __init__.py * Update run_server.py * Update __init__.py * Update __init__.py * Update run_server.py * Update README.md * Update run_server.py * Update run_server.py * Update __init__.py * Update __init__.py * Update run_server.py * Update README.md * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py
1 parent 36e6523 commit 91ed3fa

3 files changed

Lines changed: 137 additions & 16 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,32 @@ VISDOM_USE_ENV_CREDENTIALS=1 visdom -enable_login
227227
You can also use `VISDOM_COOKIE` variable to provide cookies value if the cookie file wasn't generated, or the
228228
flag `-force_new_cookie` was set.
229229

230+
#### HTTPS Support
231+
232+
To run the visdom server over HTTPS,user need to provide an SSL certificate and key file:
233+
234+
```bash
235+
# Generate a self-signed certificate (for development only)
236+
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
237+
238+
# Start the server with HTTPS
239+
python -m visdom.server -ssl_certfile cert.pem -ssl_keyfile key.pem
240+
```
241+
242+
Access the server at `https://localhost:8097`.
243+
244+
Connect via the Python client:
245+
```python
246+
# For Production - real CA-signed certificate (default)
247+
vis = visdom.Visdom(server="https://myserver.com")
248+
249+
# For Development - self signed certificate
250+
vis = visdom.Visdom(server="https://localhost", ssl_verify=False)
251+
```
252+
253+
> **Note**: `ssl_verify=False` disables certificate verification and should only be used in development with self-signed certificates. Do not use in production.
254+
255+
230256
#### Python example
231257
```python
232258
import visdom

py/visdom/__init__.py

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import os
1212
import os.path
1313
import requests
14+
import ssl
1415
import traceback
1516
import threading
1617
import websocket # type: ignore
@@ -678,10 +679,22 @@ def __init__(
678679
use_polling=False,
679680
session_idle_timeout=SESSION_IDLE_TIMEOUT,
680681
session_idle_check_interval=SESSION_IDLE_CHECK_INTERVAL,
682+
ssl_verify=None,
681683
):
682684
parsed_url = urlparse(server)
683685
if not parsed_url.scheme:
684686
parsed_url = urlparse("http://{}".format(server))
687+
688+
if parsed_url.scheme == "http" and ssl_verify is not None:
689+
raise ValueError(
690+
"ssl_verify is only valid with HTTPS. "
691+
"Use server='https://...' to enable HTTPS."
692+
)
693+
694+
if ssl_verify is None:
695+
ssl_verify = True
696+
697+
self.ssl_verify = ssl_verify
685698
self.server_base_name = parsed_url.netloc
686699
self.server = urlunparse((parsed_url.scheme, parsed_url.netloc, "", "", "", ""))
687700
self.endpoint = endpoint
@@ -748,13 +761,20 @@ def __init__(
748761
# storage for data associated with specific windows
749762

750763
# Setup for online interactions
751-
self._send(
752-
{
753-
"eid": env,
754-
},
755-
endpoint="env/" + env,
756-
)
757-
764+
result = self._send({"eid": env}, endpoint="env/" + env)
765+
if self.send and result is False:
766+
if self.raise_exceptions:
767+
raise ConnectionError(
768+
"Could not connect to server at {}:{}.".format(
769+
self.server, self.port
770+
)
771+
)
772+
else:
773+
logger.warning(
774+
"Could not connect to server at {}:{}.".format(
775+
self.server, self.port
776+
)
777+
)
758778
# when talking to a server, get a backchannel
759779
if send and use_incoming_socket:
760780
self.setup_socket()
@@ -800,6 +820,10 @@ def session(self):
800820
sess = requests.Session()
801821
if self.proxies:
802822
sess.proxies.update(self.proxies)
823+
if isinstance(self.ssl_verify, str):
824+
sess.verify = self.ssl_verify
825+
elif not self.ssl_verify:
826+
sess.verify = False
803827
if self.username:
804828
resp = sess.post(
805829
"%s:%s%s" % (self.server, self.port, self.base_url),
@@ -995,11 +1019,22 @@ def run_socket(*args):
9951019
+ self.session.cookies.get("user_password", "")
9961020
},
9971021
)
998-
ws.run_forever(
999-
http_proxy_host=self.http_proxy_host,
1000-
http_proxy_port=self.http_proxy_port,
1001-
ping_timeout=100.0,
1002-
)
1022+
run_forever_kwargs = {
1023+
"http_proxy_host": self.http_proxy_host,
1024+
"http_proxy_port": self.http_proxy_port,
1025+
"ping_timeout": 100.0,
1026+
}
1027+
if ws_scheme == "wss":
1028+
if isinstance(self.ssl_verify, str):
1029+
run_forever_kwargs["sslopt"] = {
1030+
"cert_reqs": ssl.CERT_REQUIRED,
1031+
"ca_certs": self.ssl_verify,
1032+
}
1033+
elif not self.ssl_verify:
1034+
run_forever_kwargs["sslopt"] = {
1035+
"cert_reqs": ssl.CERT_NONE,
1036+
}
1037+
ws.run_forever(**run_forever_kwargs)
10031038
ws.close()
10041039
except Exception as e:
10051040
logger.error("Socket had error {}, attempting restart".format(e))
@@ -1039,8 +1074,10 @@ def _handle_post(self, url, data=None):
10391074
self._last_post_time = time.time()
10401075
had_session = self._session is not None
10411076
try:
1042-
r = self.session.post(url, data=data)
1077+
r = self.session.post(url, data=data, timeout=(20, None))
10431078
return r.text
1079+
except requests.exceptions.SSLError:
1080+
raise
10441081
except (requests.ConnectionError, requests.Timeout):
10451082
if not had_session:
10461083
raise
@@ -1052,7 +1089,7 @@ def _handle_post(self, url, data=None):
10521089
except Exception:
10531090
pass
10541091
self._session = None
1055-
r = self.session.post(url, data=data)
1092+
r = self.session.post(url, data=data, timeout=(20, None))
10561093
return r.text
10571094

10581095
def _send(self, msg, endpoint="events", quiet=False, from_log=False, create=True):
@@ -1094,6 +1131,23 @@ def _send(self, msg, endpoint="events", quiet=False, from_log=False, create=True
10941131
),
10951132
data=json.dumps(msg, cls=NanSafeEncoder),
10961133
)
1134+
except requests.exceptions.SSLError as e:
1135+
ssl_msg = (
1136+
"SSL certificate verification failed for {}:{}. "
1137+
"If using a self-signed certificate, pass ssl_verify=False. "
1138+
"If using mkcert, run: "
1139+
"export REQUESTS_CA_BUNDLE=$(mkcert -CAROOT)/rootCA.pem".format(
1140+
self.server, self.port
1141+
)
1142+
)
1143+
if self.raise_exceptions:
1144+
raise ConnectionError(ssl_msg) from e
1145+
else:
1146+
if not quiet:
1147+
print("SSL Error:")
1148+
print("-" * 60)
1149+
print(ssl_msg)
1150+
return False
10971151
except (requests.RequestException, requests.ConnectionError, requests.Timeout):
10981152
if self.raise_exceptions:
10991153
raise ConnectionError("Error connecting to Visdom server")

py/visdom/server/run_server.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import hashlib
1717
import logging
1818
import os
19+
import ssl
1920
import sys
2021
import errno
2122
import socket
@@ -70,6 +71,8 @@ def start_server(
7071
use_frontend_client_polling=False,
7172
bind_local=False,
7273
eager_data_loading=False,
74+
ssl_certfile=None,
75+
ssl_keyfile=None,
7376
):
7477
logging.info("Server started")
7578
app = Application(
@@ -83,6 +86,21 @@ def start_server(
8386
)
8487
bind_addr = "127.0.0.1" if bind_local else None
8588
family = socket.AF_INET if bind_local else socket.AF_UNSPEC
89+
90+
ssl_ctx = None
91+
if ssl_certfile and ssl_keyfile:
92+
if not os.path.isfile(ssl_certfile):
93+
raise FileNotFoundError(f"SSL certificate file not found: {ssl_certfile}")
94+
if not os.path.isfile(ssl_keyfile):
95+
raise FileNotFoundError(f"SSL key file not found: {ssl_keyfile}")
96+
ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
97+
ssl_ctx.load_cert_chain(ssl_certfile, ssl_keyfile)
98+
logging.info("SSL enabled")
99+
100+
server = tornado.httpserver.HTTPServer(
101+
app, max_buffer_size=1024**3, ssl_options=ssl_ctx
102+
)
103+
86104
try:
87105
sockets = tornado.netutil.bind_sockets(port, address=bind_addr, family=family)
88106
except OSError as e:
@@ -94,7 +112,6 @@ def start_server(
94112
raise
95113
port = sockets[0].getsockname()[1]
96114
app.port = port
97-
server = tornado.httpserver.HTTPServer(app, max_buffer_size=1024**3)
98115
server.add_sockets(sockets)
99116

100117
logging.info("Application Started")
@@ -105,10 +122,13 @@ def start_server(
105122
if "HOSTNAME" in os.environ and hostname == DEFAULT_HOSTNAME:
106123
hostname = os.environ["HOSTNAME"]
107124

125+
scheme = "https" if ssl_ctx else "http"
126+
108127
if print_func is None:
109-
print("You can navigate to http://%s:%s%s" % (hostname, port, base_url))
128+
print("You can navigate to %s://%s:%s%s" % (scheme, hostname, port, base_url))
110129
else:
111130
print_func(port)
131+
112132
ioloop.IOLoop.current().start()
113133
app.subs = []
114134
app.sources = []
@@ -188,6 +208,22 @@ def main(print_func=None):
188208
action="store_true",
189209
help="Load data from filesystem when starting server (and not lazily upon first request).",
190210
)
211+
parser.add_argument(
212+
"-ssl_certfile",
213+
metavar="ssl_certfile",
214+
type=str,
215+
default=None,
216+
help="Path to SSL certificate file (.pem or .crt) to enable HTTPS. "
217+
"Must be used together with -ssl_keyfile.",
218+
)
219+
parser.add_argument(
220+
"-ssl_keyfile",
221+
metavar="ssl_keyfile",
222+
type=str,
223+
default=None,
224+
help="Path to SSL private key file (.pem or .key) to enable HTTPS. "
225+
"Must be used together with -ssl_certfile.",
226+
)
191227
FLAGS = parser.parse_args()
192228

193229
# Process base_url
@@ -197,6 +233,9 @@ def main(print_func=None):
197233
"/"
198234
), "base_url should not end with / as it is appended automatically"
199235

236+
if bool(FLAGS.ssl_certfile) != bool(FLAGS.ssl_keyfile):
237+
parser.error("-ssl_certfile and -ssl_keyfile must be provided together.")
238+
200239
try:
201240
logging_level = int(FLAGS.logging_level)
202241
except ValueError:
@@ -274,6 +313,8 @@ def main(print_func=None):
274313
use_frontend_client_polling=FLAGS.use_frontend_client_polling,
275314
bind_local=FLAGS.bind_local,
276315
eager_data_loading=FLAGS.eager_data_loading,
316+
ssl_certfile=FLAGS.ssl_certfile,
317+
ssl_keyfile=FLAGS.ssl_keyfile,
277318
)
278319

279320

0 commit comments

Comments
 (0)