dockerapi.py 20 KB

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