blob: 20e9d0e044a6a7a9315e202a2970837607847465 [file] [log] [blame]
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +01001#!/usr/bin/env python3
2
3from flask import Flask
4from flask_restful import Resource, Api
5from flask import jsonify
6from flask import Response
7from flask import request
8from threading import Thread
9import docker
10import uuid
11import signal
12import time
13import os
14import re
15import sys
16import ssl
17import socket
18import subprocess
19import traceback
20
21docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
22app = Flask(__name__)
23api = Api(app)
24
25class containers_get(Resource):
26 def get(self):
27 containers = {}
28 try:
29 for container in docker_client.containers.list(all=True):
30 containers.update({container.attrs['Id']: container.attrs})
31 return containers
32 except Exception as e:
33 return jsonify(type='danger', msg=str(e))
34
35class container_get(Resource):
36 def get(self, container_id):
37 if container_id and container_id.isalnum():
38 try:
39 for container in docker_client.containers.list(all=True, filters={"id": container_id}):
40 return container.attrs
41 except Exception as e:
42 return jsonify(type='danger', msg=str(e))
43 else:
44 return jsonify(type='danger', msg='no or invalid id defined')
45
46class container_post(Resource):
47 def post(self, container_id, post_action):
48 if container_id and container_id.isalnum() and post_action:
49 try:
50 """Dispatch container_post api call"""
51 if post_action == 'exec':
52 if not request.json or not 'cmd' in request.json:
53 return jsonify(type='danger', msg='cmd is missing')
54 if not request.json or not 'task' in request.json:
55 return jsonify(type='danger', msg='task is missing')
56
57 api_call_method_name = '__'.join(['container_post', str(post_action), str(request.json['cmd']), str(request.json['task']) ])
58 else:
59 api_call_method_name = '__'.join(['container_post', str(post_action) ])
60
61 api_call_method = getattr(self, api_call_method_name, lambda container_id: jsonify(type='danger', msg='container_post - unknown api call'))
62
63
64 print("api call: %s, container_id: %s" % (api_call_method_name, container_id))
65 return api_call_method(container_id)
66 except Exception as e:
67 print("error - container_post: %s" % str(e))
68 return jsonify(type='danger', msg=str(e))
69
70 else:
71 return jsonify(type='danger', msg='invalid container id or missing action')
72
73
74 # api call: container_post - post_action: stop
75 def container_post__stop(self, container_id):
76 for container in docker_client.containers.list(all=True, filters={"id": container_id}):
77 container.stop()
78 return jsonify(type='success', msg='command completed successfully')
79
80
81 # api call: container_post - post_action: start
82 def container_post__start(self, container_id):
83 for container in docker_client.containers.list(all=True, filters={"id": container_id}):
84 container.start()
85 return jsonify(type='success', msg='command completed successfully')
86
87
88 # api call: container_post - post_action: restart
89 def container_post__restart(self, container_id):
90 for container in docker_client.containers.list(all=True, filters={"id": container_id}):
91 container.restart()
92 return jsonify(type='success', msg='command completed successfully')
93
94
95 # api call: container_post - post_action: top
96 def container_post__top(self, container_id):
97 for container in docker_client.containers.list(all=True, filters={"id": container_id}):
98 return jsonify(type='success', msg=container.top())
99
100
101 # api call: container_post - post_action: stats
102 def container_post__stats(self, container_id):
103 for container in docker_client.containers.list(all=True, filters={"id": container_id}):
104 for stat in container.stats(decode=True, stream=True):
105 return jsonify(type='success', msg=stat )
106
107
108 # api call: container_post - post_action: exec - cmd: mailq - task: delete
109 def container_post__exec__mailq__delete(self, container_id):
110 if 'items' in request.json:
111 r = re.compile("^[0-9a-fA-F]+$")
112 filtered_qids = filter(r.match, request.json['items'])
113 if filtered_qids:
114 flagged_qids = ['-d %s' % i for i in filtered_qids]
115 sanitized_string = str(' '.join(flagged_qids));
116
117 for container in docker_client.containers.list(filters={"id": container_id}):
118 postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
119 return exec_run_handler('generic', postsuper_r)
120
121 # api call: container_post - post_action: exec - cmd: mailq - task: hold
122 def container_post__exec__mailq__hold(self, container_id):
123 if 'items' in request.json:
124 r = re.compile("^[0-9a-fA-F]+$")
125 filtered_qids = filter(r.match, request.json['items'])
126 if filtered_qids:
127 flagged_qids = ['-h %s' % i for i in filtered_qids]
128 sanitized_string = str(' '.join(flagged_qids));
129
130 for container in docker_client.containers.list(filters={"id": container_id}):
131 postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
132 return exec_run_handler('generic', postsuper_r)
133
134 # api call: container_post - post_action: exec - cmd: mailq - task: cat
135 def container_post__exec__mailq__cat(self, container_id):
136 if 'items' in request.json:
137 r = re.compile("^[0-9a-fA-F]+$")
138 filtered_qids = filter(r.match, request.json['items'])
139 if filtered_qids:
140 sanitized_string = str(' '.join(filtered_qids));
141
142 for container in docker_client.containers.list(filters={"id": container_id}):
143 postcat_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postcat -q " + sanitized_string], user='postfix')
144 if not postcat_return:
145 postcat_return = 'err: invalid'
146 return exec_run_handler('utf8_text_only', postcat_return)
147
148 # api call: container_post - post_action: exec - cmd: mailq - task: unhold
149 def container_post__exec__mailq__unhold(self, container_id):
150 if 'items' in request.json:
151 r = re.compile("^[0-9a-fA-F]+$")
152 filtered_qids = filter(r.match, request.json['items'])
153 if filtered_qids:
154 flagged_qids = ['-H %s' % i for i in filtered_qids]
155 sanitized_string = str(' '.join(flagged_qids));
156
157 for container in docker_client.containers.list(filters={"id": container_id}):
158 postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
159 return exec_run_handler('generic', postsuper_r)
160
161
162 # api call: container_post - post_action: exec - cmd: mailq - task: deliver
163 def container_post__exec__mailq__deliver(self, container_id):
164 if 'items' in request.json:
165 r = re.compile("^[0-9a-fA-F]+$")
166 filtered_qids = filter(r.match, request.json['items'])
167 if filtered_qids:
168 flagged_qids = ['-i %s' % i for i in filtered_qids]
169
170 for container in docker_client.containers.list(filters={"id": container_id}):
171 for i in flagged_qids:
172 postqueue_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postqueue " + i], user='postfix')
173 # todo: check each exit code
174 return jsonify(type='success', msg=str("Scheduled immediate delivery"))
175
176
177 # api call: container_post - post_action: exec - cmd: mailq - task: list
178 def container_post__exec__mailq__list(self, container_id):
179 for container in docker_client.containers.list(filters={"id": container_id}):
180 mailq_return = container.exec_run(["/usr/sbin/postqueue", "-j"], user='postfix')
181 return exec_run_handler('utf8_text_only', mailq_return)
182
183
184 # api call: container_post - post_action: exec - cmd: mailq - task: flush
185 def container_post__exec__mailq__flush(self, container_id):
186 for container in docker_client.containers.list(filters={"id": container_id}):
187 postqueue_r = container.exec_run(["/usr/sbin/postqueue", "-f"], user='postfix')
188 return exec_run_handler('generic', postqueue_r)
189
190
191 # api call: container_post - post_action: exec - cmd: mailq - task: super_delete
192 def container_post__exec__mailq__super_delete(self, container_id):
193 for container in docker_client.containers.list(filters={"id": container_id}):
194 postsuper_r = container.exec_run(["/usr/sbin/postsuper", "-d", "ALL"])
195 return exec_run_handler('generic', postsuper_r)
196
197
198 # api call: container_post - post_action: exec - cmd: system - task: fts_rescan
199 def container_post__exec__system__fts_rescan(self, container_id):
200 if 'username' in request.json:
201 for container in docker_client.containers.list(filters={"id": container_id}):
202 rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -u '" + request.json['username'].replace("'", "'\\''") + "'"], user='vmail')
203 if rescan_return.exit_code == 0:
204 return jsonify(type='success', msg='fts_rescan: rescan triggered')
205 else:
206 return jsonify(type='warning', msg='fts_rescan error')
207
208 if 'all' in request.json:
209 for container in docker_client.containers.list(filters={"id": container_id}):
210 rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -A"], user='vmail')
211 if rescan_return.exit_code == 0:
212 return jsonify(type='success', msg='fts_rescan: rescan triggered')
213 else:
214 return jsonify(type='warning', msg='fts_rescan error')
215
216
217 # api call: container_post - post_action: exec - cmd: system - task: df
218 def container_post__exec__system__df(self, container_id):
219 if 'dir' in request.json:
220 for container in docker_client.containers.list(filters={"id": container_id}):
221 df_return = container.exec_run(["/bin/bash", "-c", "/bin/df -H '" + request.json['dir'].replace("'", "'\\''") + "' | /usr/bin/tail -n1 | /usr/bin/tr -s [:blank:] | /usr/bin/tr ' ' ','"], user='nobody')
222 if df_return.exit_code == 0:
223 return df_return.output.decode('utf-8').rstrip()
224 else:
225 return "0,0,0,0,0,0"
226
227
228 # api call: container_post - post_action: exec - cmd: system - task: mysql_upgrade
229 def container_post__exec__system__mysql_upgrade(self, container_id):
230 for container in docker_client.containers.list(filters={"id": container_id}):
231 sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_upgrade -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "'\n"], user='mysql')
232 if sql_return.exit_code == 0:
233 matched = False
234 for line in sql_return.output.decode('utf-8').split("\n"):
235 if 'is already upgraded to' in line:
236 matched = True
237 if matched:
238 return jsonify(type='success', msg='mysql_upgrade: already upgraded', text=sql_return.output.decode('utf-8'))
239 else:
240 container.restart()
241 return jsonify(type='warning', msg='mysql_upgrade: upgrade was applied', text=sql_return.output.decode('utf-8'))
242 else:
243 return jsonify(type='error', msg='mysql_upgrade: error running command', text=sql_return.output.decode('utf-8'))
244
245 # api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
246 def container_post__exec__system__mysql_tzinfo_to_sql(self, container_id):
247 for container in docker_client.containers.list(filters={"id": container_id}):
248 sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_tzinfo_to_sql /usr/share/zoneinfo | /bin/sed 's/Local time zone must be set--see zic manual page/FCTY/' | /usr/bin/mysql -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "' mysql \n"], user='mysql')
249 if sql_return.exit_code == 0:
250 return jsonify(type='info', msg='mysql_tzinfo_to_sql: command completed successfully', text=sql_return.output.decode('utf-8'))
251 else:
252 return jsonify(type='error', msg='mysql_tzinfo_to_sql: error running command', text=sql_return.output.decode('utf-8'))
253
254 # api call: container_post - post_action: exec - cmd: reload - task: dovecot
255 def container_post__exec__reload__dovecot(self, container_id):
256 for container in docker_client.containers.list(filters={"id": container_id}):
257 reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
258 return exec_run_handler('generic', reload_return)
259
260
261 # api call: container_post - post_action: exec - cmd: reload - task: postfix
262 def container_post__exec__reload__postfix(self, container_id):
263 for container in docker_client.containers.list(filters={"id": container_id}):
264 reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
265 return exec_run_handler('generic', reload_return)
266
267
268 # api call: container_post - post_action: exec - cmd: reload - task: nginx
269 def container_post__exec__reload__nginx(self, container_id):
270 for container in docker_client.containers.list(filters={"id": container_id}):
271 reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
272 return exec_run_handler('generic', reload_return)
273
274
275 # api call: container_post - post_action: exec - cmd: sieve - task: list
276 def container_post__exec__sieve__list(self, container_id):
277 if 'username' in request.json:
278 for container in docker_client.containers.list(filters={"id": container_id}):
279 sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request.json['username'].replace("'", "'\\''") + "'"])
280 return exec_run_handler('utf8_text_only', sieve_return)
281
282
283 # api call: container_post - post_action: exec - cmd: sieve - task: print
284 def container_post__exec__sieve__print(self, container_id):
285 if 'username' in request.json and 'script_name' in request.json:
286 for container in docker_client.containers.list(filters={"id": container_id}):
287 cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request.json['username'].replace("'", "'\\''") + "' '" + request.json['script_name'].replace("'", "'\\''") + "'"]
288 sieve_return = container.exec_run(cmd)
289 return exec_run_handler('utf8_text_only', sieve_return)
290
291
292 # api call: container_post - post_action: exec - cmd: maildir - task: cleanup
293 def container_post__exec__maildir__cleanup(self, container_id):
294 if 'maildir' in request.json:
295 for container in docker_client.containers.list(filters={"id": container_id}):
296 sane_name = re.sub(r'\W+', '', request.json['maildir'])
297 cmd = ["/bin/bash", "-c", "if [[ -d '/var/vmail/" + request.json['maildir'].replace("'", "'\\''") + "' ]]; then /bin/mv '/var/vmail/" + request.json['maildir'].replace("'", "'\\''") + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "'; fi"]
298 maildir_cleanup = container.exec_run(cmd, user='vmail')
299 return exec_run_handler('generic', maildir_cleanup)
300
301
302
303 # api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
304 def container_post__exec__rspamd__worker_password(self, container_id):
305 if 'raw' in request.json:
306 for container in docker_client.containers.list(filters={"id": container_id}):
307 cmd = "/usr/bin/rspamadm pw -e -p '" + request.json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
308 cmd_response = exec_cmd_container(container, cmd, user="_rspamd")
309 matched = False
310 for line in cmd_response.split("\n"):
311 if '$2$' in line:
312 hash = line.strip()
313 hash_out = re.search('\$2\$.+$', hash).group(0)
314 rspamd_passphrase_hash = re.sub('[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
315
316 rspamd_password_filename = "/etc/rspamd/override.d/worker-controller-password.inc"
317 cmd = '''/bin/echo 'enable_password = "%s";' > %s && cat %s''' % (rspamd_passphrase_hash, rspamd_password_filename, rspamd_password_filename)
318 cmd_response = exec_cmd_container(container, cmd, user="_rspamd")
319
320 if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
321 container.restart()
322 matched = True
323
324 if matched:
325 return jsonify(type='success', msg='command completed successfully')
326 else:
327 return jsonify(type='danger', msg='command did not complete')
328
329def exec_cmd_container(container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
330
331 def recv_socket_data(c_socket, timeout):
332 c_socket.setblocking(0)
333 total_data=[];
334 data='';
335 begin=time.time()
336 while True:
337 if total_data and time.time()-begin > timeout:
338 break
339 elif time.time()-begin > timeout*2:
340 break
341 try:
342 data = c_socket.recv(8192)
343 if data:
344 total_data.append(data.decode('utf-8'))
345 #change the beginning time for measurement
346 begin=time.time()
347 else:
348 #sleep for sometime to indicate a gap
349 time.sleep(0.1)
350 break
351 except:
352 pass
353 return ''.join(total_data)
354
355 try :
356 socket = container.exec_run([shell_cmd], stdin=True, socket=True, user=user).output._sock
357 if not cmd.endswith("\n"):
358 cmd = cmd + "\n"
359 socket.send(cmd.encode('utf-8'))
360 data = recv_socket_data(socket, timeout)
361 socket.close()
362 return data
363
364 except Exception as e:
365 print("error - exec_cmd_container: %s" % str(e))
366 traceback.print_exc(file=sys.stdout)
367
368def exec_run_handler(type, output):
369 if type == 'generic':
370 if output.exit_code == 0:
371 return jsonify(type='success', msg='command completed successfully')
372 else:
373 return jsonify(type='danger', msg='command failed: ' + output.output.decode('utf-8'))
374 if type == 'utf8_text_only':
375 r = Response(response=output.output.decode('utf-8'), status=200, mimetype="text/plain")
376 r.headers["Content-Type"] = "text/plain; charset=utf-8"
377 return r
378
379class GracefulKiller:
380 kill_now = False
381 def __init__(self):
382 signal.signal(signal.SIGINT, self.exit_gracefully)
383 signal.signal(signal.SIGTERM, self.exit_gracefully)
384
385 def exit_gracefully(self, signum, frame):
386 self.kill_now = True
387
388def create_self_signed_cert():
389 process = subprocess.Popen(
390 "openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes -keyout /app/dockerapi_key.pem -out /app/dockerapi_cert.pem -subj /CN=dockerapi/O=mailcow -addext subjectAltName=DNS:dockerapi".split(),
391 stdout = subprocess.PIPE, stderr = subprocess.PIPE, shell=False
392 )
393 process.wait()
394
395def startFlaskAPI():
396 create_self_signed_cert()
397 try:
398 ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
399 ctx.check_hostname = False
400 ctx.load_cert_chain(certfile='/app/dockerapi_cert.pem', keyfile='/app/dockerapi_key.pem')
401 except:
402 print ("Cannot initialize TLS, retrying in 5s...")
403 time.sleep(5)
404 app.run(debug=False, host='0.0.0.0', port=443, threaded=True, ssl_context=ctx)
405
406api.add_resource(containers_get, '/containers/json')
407api.add_resource(container_get, '/containers/<string:container_id>/json')
408api.add_resource(container_post, '/containers/<string:container_id>/<string:post_action>')
409
410if __name__ == '__main__':
411 api_thread = Thread(target=startFlaskAPI)
412 api_thread.daemon = True
413 api_thread.start()
414 killer = GracefulKiller()
415 while True:
416 time.sleep(1)
417 if killer.kill_now:
418 break
419 print ("Stopping dockerapi-mailcow")