Skip to content

Commit 68b2343

Browse files
committed
Replace XML-RPC with gRPC for PyMOL RPC services
Introduced a new gRPC-based implementation to replace the deprecated XML-RPC framework used in PyMOL. This migration addresses security vulnerabilities in XML-RPC (e.g., CVE-2016-5004) and provides a modern, efficient, and extensible framework for remote procedure calls. Updated associated protobuf definitions and Python gRPC bindings accordingly.
1 parent 11ed6b6 commit 68b2343

File tree

5 files changed

+1618
-1
lines changed

5 files changed

+1618
-1
lines changed

modules/pymol/invocation.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,17 @@ def parse_args(argv, _pymol=None, options=None, restricted=0):
519519
for a in pymolrc] + options.deferred
520520
options.pymolrc = pymolrc
521521
if options.rpcServer:
522-
options.deferred.append('_do__ /import pymol.rpc;pymol.rpc.launch_XMLRPC()')
522+
# Replaced old XML-RPC implementation with modern gRPC implementation.
523+
# However, to enforce this change, it uses the same command line argument.
524+
# This enforcement comes from clear security concerns, like vulnerabilities in the
525+
# Apache XML RPC Client Library (https://mvnrepository.com/artifact/org.apache.xmlrpc/xmlrpc-client/3.1.3)
526+
# that has its last official release in 2010.
527+
# It contains two Vulnerabilities from dependencies:
528+
# CVE-2016-5004: https://www.cve.org/CVERecord?id=CVE-2016-5004
529+
# CVE-2012-5783: https://www.cve.org/CVERecord?id=CVE-2012-5783
530+
#
531+
# options.deferred.append('_do__ /import pymol.rpc;pymol.rpc.launch_XMLRPC()')
532+
options.deferred.append('_do__ /import pymol.pml_grpc;pymol.pml_grpc.launch_gRPC()')
523533
if options.plugins == 1:
524534
# Load plugins independent of PMGApp (will not add menu items)
525535
options.deferred.append('_do__ /import pymol.plugins;pymol.plugins.initialize(-1)')

modules/pymol/pml_grpc.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"""
2+
A gRPC server to allow remote control of PyMol.
3+
4+
Replaces the legacy XML-RPC implementation with a modern,
5+
high-performance gRPC service.
6+
7+
Author: Greg Landrum (original XML-RPC), Martin Urban (updated for gRPC)
8+
Date: September 2025
9+
License: PyMOL
10+
Requires:
11+
- grpcio, grpcio-tools, protobuf
12+
- Python with threading enabled
13+
"""
14+
15+
import os
16+
import tempfile
17+
from concurrent import futures
18+
19+
import grpc
20+
from pymol import cmd, cgo
21+
22+
import pymol_rpc_pb2
23+
import pymol_rpc_pb2_grpc
24+
25+
# --- Global state ---
26+
cgoDict = {} # stores CGO objects by id
27+
_server = None
28+
29+
30+
# --- Helper functions ---
31+
32+
def _color_obj(obj_name, color_scheme: str):
33+
"""Applies a color scheme to a molecular object."""
34+
if not color_scheme:
35+
return
36+
try:
37+
if color_scheme == 'std':
38+
cmd.color("magenta", f"({obj_name})", quiet=1)
39+
cmd.color("oxygen", f"(elem O and {obj_name})", quiet=1)
40+
cmd.color("nitrogen", f"(elem N and {obj_name})", quiet=1)
41+
cmd.color("sulfur", f"(elem S and {obj_name})", quiet=1)
42+
cmd.color("hydrogen", f"(elem H and {obj_name})", quiet=1)
43+
cmd.color("gray", f"(elem C and {obj_name})", quiet=1)
44+
else:
45+
cmd.color(color_scheme, obj_name, quiet=1)
46+
except Exception as e:
47+
print(f"Error applying color scheme '{color_scheme}': {e}")
48+
49+
50+
def _make_alpha_section(transparent: bool, transparency: float):
51+
return [] if not transparent else [cgo.ALPHA, 1.0 - transparency]
52+
53+
54+
# --- gRPC Servicer Implementation ---
55+
56+
class PyMolRPCServicer(pymol_rpc_pb2_grpc.PyMolRPCServicer):
57+
"""Implements the PyMolRPC gRPC service."""
58+
59+
def Ping(self, request, context):
60+
return pymol_rpc_pb2.StatusResponse(success=True, message="Pong")
61+
62+
def Do(self, request, context):
63+
try:
64+
result = cmd.do(request.command)
65+
return pymol_rpc_pb2.CommandResponse(result=str(result) if result else "")
66+
except Exception as e:
67+
return pymol_rpc_pb2.CommandResponse(result=f"Error: {e}")
68+
69+
def Label(self, request, context):
70+
try:
71+
pos = (request.pos.x, request.pos.y, request.pos.z)
72+
color = (request.color.r, request.color.g, request.color.b)
73+
obj_id = request.id or 'lab1'
74+
color_id = f"{obj_id}-color"
75+
76+
cmd.pseudoatom(obj_id, label=repr(request.text), elem='C', pos=pos)
77+
cmd.set_color(color_id, color)
78+
cmd.color(color_id, obj_id)
79+
return pymol_rpc_pb2.StatusResponse(success=True)
80+
except Exception as e:
81+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
82+
83+
def Sphere(self, request, context):
84+
try:
85+
pos = (request.pos.x, request.pos.y, request.pos.z)
86+
color = (request.color.r, request.color.g, request.color.b)
87+
obj_id = request.id or 'cgo'
88+
89+
obj = cgoDict.get(obj_id, []).copy() if request.extend else []
90+
obj.extend(_make_alpha_section(request.transparent, request.transparency))
91+
obj.extend([cgo.COLOR, *color, cgo.SPHERE, *pos, request.radius])
92+
93+
cgoDict[obj_id] = obj
94+
cmd.load_cgo(obj, obj_id, 1)
95+
return pymol_rpc_pb2.StatusResponse(success=True)
96+
except Exception as e:
97+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
98+
99+
def Cylinder(self, request, context):
100+
try:
101+
p1 = (request.end1.x, request.end1.y, request.end1.z)
102+
p2 = (request.end2.x, request.end2.y, request.end2.z)
103+
c1 = (request.color1.r, request.color1.g, request.color1.b)
104+
105+
if request.HasField("color2"):
106+
c2 = (request.color2.r, request.color2.g, request.color2.b)
107+
else:
108+
c2 = c1
109+
110+
obj_id = request.id or 'cgo'
111+
obj = cgoDict.get(obj_id, []).copy() if request.extend else []
112+
obj.extend(_make_alpha_section(request.transparent, request.transparency))
113+
obj.extend([cgo.CYLINDER, *p1, *p2, request.radius, *c1, *c2])
114+
115+
cgoDict[obj_id] = obj
116+
cmd.load_cgo(obj, obj_id, 1)
117+
return pymol_rpc_pb2.StatusResponse(success=True)
118+
except Exception as e:
119+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
120+
121+
def LoadPDB(self, request, context):
122+
if request.replace:
123+
cmd.delete(request.obj_name)
124+
result = cmd.read_pdbstr(request.data, request.obj_name)
125+
if request.HasField("color_scheme"):
126+
_color_obj(request.obj_name, request.color_scheme)
127+
return pymol_rpc_pb2.CommandResponse(result=str(result) if result else "")
128+
129+
def LoadMolBlock(self, request, context):
130+
if request.replace:
131+
cmd.delete(request.obj_name)
132+
result = cmd.read_molstr(request.data, request.obj_name)
133+
if request.HasField("color_scheme"):
134+
_color_obj(request.obj_name, request.color_scheme)
135+
return pymol_rpc_pb2.CommandResponse(result=str(result) if result else "")
136+
137+
def LoadFile(self, request, context):
138+
obj_name = request.obj_name or os.path.splitext(os.path.basename(request.file_name))[0]
139+
if request.replace:
140+
cmd.delete(obj_name)
141+
fmt = request.format if request.HasField("format") else ''
142+
result = cmd.load(request.file_name, obj_name, format=fmt)
143+
if request.HasField("color_scheme"):
144+
_color_obj(obj_name, request.color_scheme)
145+
return pymol_rpc_pb2.CommandResponse(result=str(result) if result else "")
146+
147+
def LoadSurface(self, request, context):
148+
grid_name = f"grid-{request.obj_name}"
149+
file_to_load = None
150+
151+
try:
152+
if request.WhichOneof("source") == "data":
153+
with tempfile.NamedTemporaryFile(delete=False, mode='w', suffix='.grd') as tmp:
154+
tmp.write(request.data)
155+
file_to_load = tmp.name
156+
elif request.WhichOneof("source") == "file_name":
157+
file_to_load = request.file_name
158+
else:
159+
return pymol_rpc_pb2.CommandResponse(result="Error: no source specified")
160+
161+
fmt = request.format if request.HasField("format") else ''
162+
result = cmd.load(file_to_load, grid_name, format=fmt)
163+
cmd.isosurface(request.obj_name, grid_name, level=request.surface_level)
164+
return pymol_rpc_pb2.CommandResponse(result=str(result) if result else "")
165+
finally:
166+
if file_to_load and file_to_load.endswith(".grd") and os.path.exists(file_to_load):
167+
os.unlink(file_to_load)
168+
169+
def GetNames(self, request, context):
170+
names = cmd.get_names(request.what, enabled_only=request.enabled_only)
171+
return pymol_rpc_pb2.GetNamesResponse(names=names)
172+
173+
def GetAtomCoords(self, request, context):
174+
coords = cmd.get_atom_coords(request.selection, state=request.state)
175+
if coords is None:
176+
return pymol_rpc_pb2.GetAtomCoordsResponse(coordinates=[])
177+
response_coords = [pymol_rpc_pb2.Vector3(x=c[0], y=c[1], z=c[2]) for c in coords]
178+
return pymol_rpc_pb2.GetAtomCoordsResponse(coordinates=response_coords)
179+
180+
def DeleteObject(self, request, context):
181+
try:
182+
cmd.delete(request.name)
183+
return pymol_rpc_pb2.StatusResponse(success=True)
184+
except Exception as e:
185+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
186+
187+
def DeleteAll(self, request, context):
188+
try:
189+
cmd.delete("all")
190+
return pymol_rpc_pb2.StatusResponse(success=True)
191+
except Exception as e:
192+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
193+
194+
def Center(self, request, context):
195+
try:
196+
cmd.center(request.selection, animate=int(request.animate))
197+
return pymol_rpc_pb2.StatusResponse(success=True)
198+
except Exception as e:
199+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
200+
201+
def Zoom(self, request, context):
202+
try:
203+
cmd.zoom(request.selection, buffer=request.buffer,
204+
state=request.state, animate=int(request.animate))
205+
return pymol_rpc_pb2.StatusResponse(success=True)
206+
except Exception as e:
207+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
208+
209+
def Rotate(self, request, context):
210+
try:
211+
cmd.rotate(request.axis, request.angle, request.selection)
212+
return pymol_rpc_pb2.StatusResponse(success=True)
213+
except Exception as e:
214+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
215+
216+
def Move(self, request, context):
217+
try:
218+
cmd.move(request.axis, request.distance, request.selection)
219+
return pymol_rpc_pb2.StatusResponse(success=True)
220+
except Exception as e:
221+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
222+
223+
def Hide(self, request, context):
224+
try:
225+
cmd.hide(request.representation, request.selection)
226+
return pymol_rpc_pb2.StatusResponse(success=True)
227+
except Exception as e:
228+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
229+
230+
def Show(self, request, context):
231+
try:
232+
cmd.show(request.representation, request.selection)
233+
return pymol_rpc_pb2.StatusResponse(success=True)
234+
except Exception as e:
235+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
236+
237+
def Color(self, request, context):
238+
try:
239+
cmd.color(request.color, request.selection)
240+
return pymol_rpc_pb2.StatusResponse(success=True)
241+
except Exception as e:
242+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
243+
244+
def SetTransparency(self, request, context):
245+
try:
246+
cmd.set("transparency", request.transparency, request.selection)
247+
return pymol_rpc_pb2.StatusResponse(success=True)
248+
except Exception as e:
249+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
250+
251+
def Select(self, request, context):
252+
try:
253+
cmd.select("sele", request.selection)
254+
return pymol_rpc_pb2.StatusResponse(success=True)
255+
except Exception as e:
256+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
257+
258+
def DeleteSelection(self, request, context):
259+
try:
260+
cmd.delete(request.selection)
261+
return pymol_rpc_pb2.StatusResponse(success=True)
262+
except Exception as e:
263+
return pymol_rpc_pb2.StatusResponse(success=False, message=str(e))
264+
265+
# --- Server Launching Logic ---
266+
267+
def launch_gRPC(hostname='', port=50051, n_to_try=5):
268+
"""Launches the gRPC server in a background thread."""
269+
global _server
270+
if _server is not None:
271+
print("gRPC server already running.")
272+
return
273+
274+
if not hostname:
275+
hostname = os.environ.get('PYMOL_RPCHOST', 'localhost')
276+
277+
server_port = None
278+
for i in range(n_to_try):
279+
current_port = port + i
280+
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
281+
pymol_rpc_pb2_grpc.add_PyMolRPCServicer_to_server(PyMolRPCServicer(), server)
282+
bound = server.add_insecure_port(f"{hostname}:{current_port}")
283+
if bound == 0:
284+
print(f"Port {current_port} unavailable; trying next")
285+
continue
286+
server.start()
287+
_server = server
288+
server_port = current_port
289+
break
290+
291+
if _server:
292+
print(f"gRPC server running on host {hostname}, port {server_port}")
293+
else:
294+
print("gRPC server could not be started")

0 commit comments

Comments
 (0)