blob: 32824a2d074eb224fb34181731538061116eb1ca [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):
Matthias Andreas Benkardc55bfae2021-01-02 07:35:21 +0100230 return jsonify(type='success', msg='mysql_upgrade: not touching fake MySQL', text='')
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100231
232 # api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
233 def container_post__exec__system__mysql_tzinfo_to_sql(self, container_id):
Matthias Andreas Benkardc55bfae2021-01-02 07:35:21 +0100234 return jsonify(type='success', msg='mysql_tzinfo_to_sql: not touching fake MySQL', text='')
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100235
236 # api call: container_post - post_action: exec - cmd: reload - task: dovecot
237 def container_post__exec__reload__dovecot(self, container_id):
238 for container in docker_client.containers.list(filters={"id": container_id}):
239 reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
240 return exec_run_handler('generic', reload_return)
241
242
243 # api call: container_post - post_action: exec - cmd: reload - task: postfix
244 def container_post__exec__reload__postfix(self, container_id):
245 for container in docker_client.containers.list(filters={"id": container_id}):
246 reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
247 return exec_run_handler('generic', reload_return)
248
249
250 # api call: container_post - post_action: exec - cmd: reload - task: nginx
251 def container_post__exec__reload__nginx(self, container_id):
252 for container in docker_client.containers.list(filters={"id": container_id}):
253 reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
254 return exec_run_handler('generic', reload_return)
255
256
257 # api call: container_post - post_action: exec - cmd: sieve - task: list
258 def container_post__exec__sieve__list(self, container_id):
259 if 'username' in request.json:
260 for container in docker_client.containers.list(filters={"id": container_id}):
261 sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request.json['username'].replace("'", "'\\''") + "'"])
262 return exec_run_handler('utf8_text_only', sieve_return)
263
264
265 # api call: container_post - post_action: exec - cmd: sieve - task: print
266 def container_post__exec__sieve__print(self, container_id):
267 if 'username' in request.json and 'script_name' in request.json:
268 for container in docker_client.containers.list(filters={"id": container_id}):
Matthias Andreas Benkardc55bfae2021-01-02 07:35:21 +0100269 cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request.json['username'].replace("'", "'\\''") + "' '" + request.json['script_name'].replace("'", "'\\''") + "'"]
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100270 sieve_return = container.exec_run(cmd)
271 return exec_run_handler('utf8_text_only', sieve_return)
272
273
274 # api call: container_post - post_action: exec - cmd: maildir - task: cleanup
275 def container_post__exec__maildir__cleanup(self, container_id):
276 if 'maildir' in request.json:
277 for container in docker_client.containers.list(filters={"id": container_id}):
278 sane_name = re.sub(r'\W+', '', request.json['maildir'])
279 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"]
280 maildir_cleanup = container.exec_run(cmd, user='vmail')
281 return exec_run_handler('generic', maildir_cleanup)
282
283
284
285 # api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
286 def container_post__exec__rspamd__worker_password(self, container_id):
287 if 'raw' in request.json:
288 for container in docker_client.containers.list(filters={"id": container_id}):
289 cmd = "/usr/bin/rspamadm pw -e -p '" + request.json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
290 cmd_response = exec_cmd_container(container, cmd, user="_rspamd")
291 matched = False
292 for line in cmd_response.split("\n"):
293 if '$2$' in line:
294 hash = line.strip()
295 hash_out = re.search('\$2\$.+$', hash).group(0)
296 rspamd_passphrase_hash = re.sub('[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
297
298 rspamd_password_filename = "/etc/rspamd/override.d/worker-controller-password.inc"
299 cmd = '''/bin/echo 'enable_password = "%s";' > %s && cat %s''' % (rspamd_passphrase_hash, rspamd_password_filename, rspamd_password_filename)
300 cmd_response = exec_cmd_container(container, cmd, user="_rspamd")
301
302 if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
303 container.restart()
304 matched = True
305
306 if matched:
307 return jsonify(type='success', msg='command completed successfully')
308 else:
309 return jsonify(type='danger', msg='command did not complete')
310
311def exec_cmd_container(container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
312
313 def recv_socket_data(c_socket, timeout):
314 c_socket.setblocking(0)
315 total_data=[];
316 data='';
317 begin=time.time()
318 while True:
319 if total_data and time.time()-begin > timeout:
320 break
321 elif time.time()-begin > timeout*2:
322 break
323 try:
324 data = c_socket.recv(8192)
325 if data:
326 total_data.append(data.decode('utf-8'))
327 #change the beginning time for measurement
328 begin=time.time()
329 else:
330 #sleep for sometime to indicate a gap
331 time.sleep(0.1)
332 break
333 except:
334 pass
335 return ''.join(total_data)
336
337 try :
338 socket = container.exec_run([shell_cmd], stdin=True, socket=True, user=user).output._sock
339 if not cmd.endswith("\n"):
340 cmd = cmd + "\n"
341 socket.send(cmd.encode('utf-8'))
342 data = recv_socket_data(socket, timeout)
343 socket.close()
344 return data
345
346 except Exception as e:
347 print("error - exec_cmd_container: %s" % str(e))
348 traceback.print_exc(file=sys.stdout)
349
350def exec_run_handler(type, output):
351 if type == 'generic':
352 if output.exit_code == 0:
353 return jsonify(type='success', msg='command completed successfully')
354 else:
355 return jsonify(type='danger', msg='command failed: ' + output.output.decode('utf-8'))
356 if type == 'utf8_text_only':
357 r = Response(response=output.output.decode('utf-8'), status=200, mimetype="text/plain")
358 r.headers["Content-Type"] = "text/plain; charset=utf-8"
359 return r
360
361class GracefulKiller:
362 kill_now = False
363 def __init__(self):
364 signal.signal(signal.SIGINT, self.exit_gracefully)
365 signal.signal(signal.SIGTERM, self.exit_gracefully)
366
367 def exit_gracefully(self, signum, frame):
368 self.kill_now = True
369
370def create_self_signed_cert():
371 process = subprocess.Popen(
372 "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(),
373 stdout = subprocess.PIPE, stderr = subprocess.PIPE, shell=False
374 )
375 process.wait()
376
377def startFlaskAPI():
378 create_self_signed_cert()
379 try:
380 ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
381 ctx.check_hostname = False
382 ctx.load_cert_chain(certfile='/app/dockerapi_cert.pem', keyfile='/app/dockerapi_key.pem')
383 except:
384 print ("Cannot initialize TLS, retrying in 5s...")
385 time.sleep(5)
386 app.run(debug=False, host='0.0.0.0', port=443, threaded=True, ssl_context=ctx)
387
388api.add_resource(containers_get, '/containers/json')
389api.add_resource(container_get, '/containers/<string:container_id>/json')
390api.add_resource(container_post, '/containers/<string:container_id>/<string:post_action>')
391
392if __name__ == '__main__':
393 api_thread = Thread(target=startFlaskAPI)
394 api_thread.daemon = True
395 api_thread.start()
396 killer = GracefulKiller()
397 while True:
398 time.sleep(1)
399 if killer.kill_now:
400 break
401 print ("Stopping dockerapi-mailcow")