forked from 3wordchant/capsul-flask
		
	Merge branch 'master' of giit.cyberia.club:~forest/capsul-flask
This commit is contained in:
		
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @ -28,6 +28,7 @@ stripe = "*" | ||||
| matplotlib = "*" | ||||
| requests = "*" | ||||
| python-dotenv = "*" | ||||
| ecdsa = "*" | ||||
|  | ||||
| [dev-packages] | ||||
|  | ||||
|  | ||||
							
								
								
									
										10
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | ||||
| { | ||||
|     "_meta": { | ||||
|         "hash": { | ||||
|             "sha256": "4f61804f9405ff8f66e41739aeac24a2577f7b64f7d08b93459b568528c4f494" | ||||
|             "sha256": "89f5b91fef8e0029cbecf7f83ce1fdc64a64f295161c8c63bfe5940e31d466e1" | ||||
|         }, | ||||
|         "pipfile-spec": 6, | ||||
|         "requires": { | ||||
| @ -60,6 +60,14 @@ | ||||
|             ], | ||||
|             "version": "==0.10.0" | ||||
|         }, | ||||
|         "ecdsa": { | ||||
|             "hashes": [ | ||||
|                 "sha256:867ec9cf6df0b03addc8ef66b56359643cb5d0c1dc329df76ba7ecfe256c8061", | ||||
|                 "sha256:8f12ac317f8a1318efa75757ef0a651abe12e51fc1af8838fb91079445227277" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==0.15" | ||||
|         }, | ||||
|         "flask": { | ||||
|             "hashes": [ | ||||
|                 "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", | ||||
|  | ||||
| @ -152,10 +152,8 @@ And you should see the token in the btcpay server UI: | ||||
|  | ||||
| Now simply set your `BTCPAY_PRIVATE_KEY` variable in `.env` | ||||
|  | ||||
| NOTE: make sure to use single quotes and replace the new lines with \n. | ||||
|  | ||||
| ``` | ||||
| BTCPAY_PRIVATE_KEY='-----BEGIN EC PRIVATE KEY----- | ||||
| EXAMPLEIArx/EXAMPLEKH23EXAMPLEsYXEXAMPLE5qdEXAMPLEcFHoAcEXAMPLEK | ||||
| oUQDQgAEnWs47PT8+ihhzyvXX6/yYMAWWODluRTR2Ix6ZY7Z+MV7v0W1maJzqeqq | ||||
| NQ+cpBvPDbyrDk9+Uf/sEaRCma094g== | ||||
| -----END EC PRIVATE KEY-----' | ||||
| BTCPAY_PRIVATE_KEY='-----BEGIN EC PRIVATE KEY-----\nEXAMPLEIArx/EXAMPLEKH23EXAMPLEsYXEXAMPLE5qdEXAMPLEcFHoAcEXAMPLEK\noUQDQgAEnWs47PT8+ihhzyvXX6/yYMAWWODluRTR2Ix6ZY7Z+MV7v0W1maJzqeqq\nNQ+cpBvPDbyrDk9+Uf/sEaRCma094g==\n-----END EC PRIVATE KEY-----' | ||||
| ``` | ||||
| @ -71,6 +71,10 @@ def magiclink(token): | ||||
|         session["account"] = email | ||||
|         return redirect(url_for("console.index")) | ||||
|     else: | ||||
|         # this is here to prevent xss | ||||
|         if token and not re.match(r"^[a-zA-Z0-9_-]+$", token): | ||||
|           token = '___________' | ||||
|  | ||||
|         abort(404, f"Token {token} doesn't exist or has already been used.") | ||||
|  | ||||
| @bp.route("/logout") | ||||
|  | ||||
| @ -8,6 +8,8 @@ from flask import g | ||||
| from flask import request | ||||
| from flask import session | ||||
| from flask import render_template | ||||
| from flask import redirect | ||||
| from flask import url_for | ||||
| from flask_mail import Message | ||||
| from werkzeug.exceptions import abort | ||||
| from nanoid import generate | ||||
| @ -15,6 +17,7 @@ from nanoid import generate | ||||
| from capsulflask.metrics import durations as metric_durations | ||||
| from capsulflask.auth import account_required | ||||
| from capsulflask.db import get_model, my_exec_info_message | ||||
| from capsulflask.payment import poll_btcpay_session | ||||
| from capsulflask import cli | ||||
|  | ||||
| bp = Blueprint("console", __name__, url_prefix="/console") | ||||
| @ -41,6 +44,11 @@ def double_check_capsul_address(id, ipv4): | ||||
| @account_required | ||||
| def index(): | ||||
|   vms = get_vms() | ||||
|   created = request.args.get('created') | ||||
|  | ||||
|   # this is here to prevent xss | ||||
|   if created and not re.match(r"^(cvm|capsul)-[a-z0-9]{10}$", created): | ||||
|     created = '___________' | ||||
|  | ||||
|   # for now we are going to check the IP according to the virt model | ||||
|   # on every request. this could be done by a background job and cached later on... | ||||
| @ -59,7 +67,7 @@ def index(): | ||||
|     list(filter(lambda x: not x['deleted'], vms)) | ||||
|   )) | ||||
|  | ||||
|   return render_template("capsuls.html", vms=vms, has_vms=len(vms) > 0) | ||||
|   return render_template("capsuls.html", vms=vms, has_vms=len(vms) > 0, created=created) | ||||
|  | ||||
| @bp.route("/<string:id>", methods=("GET", "POST")) | ||||
| @account_required | ||||
| @ -110,7 +118,6 @@ def create(): | ||||
|   account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow()) | ||||
|   capacity_avaliable = current_app.config["VIRTUALIZATION_MODEL"].capacity_avaliable(512*1024*1024) | ||||
|   errors = list() | ||||
|   created_os = None | ||||
|  | ||||
|   if request.method == "POST": | ||||
|  | ||||
| @ -171,7 +178,8 @@ def create(): | ||||
|         memory_mb=vm_sizes[size]['memory_mb'], | ||||
|         ssh_public_keys=list(map(lambda x: x["content"], posted_keys)) | ||||
|       ) | ||||
|       created_os = os | ||||
|        | ||||
|       return redirect(f"{url_for('console.index')}?created={id}") | ||||
|    | ||||
|   affordable_vm_sizes = dict() | ||||
|   for key, vm_size in vm_sizes.items(): | ||||
| @ -186,7 +194,6 @@ def create(): | ||||
|  | ||||
|   return render_template( | ||||
|     "create-capsul.html", | ||||
|     created_os=created_os, | ||||
|     capacity_avaliable=capacity_avaliable, | ||||
|     account_balance=format(account_balance, '.2f'), | ||||
|     ssh_public_keys=ssh_public_keys, | ||||
| @ -262,12 +269,18 @@ def get_payments(): | ||||
|  | ||||
| average_number_of_days_in_a_month = 30.44 | ||||
|  | ||||
| def get_vm_months_float(vm, as_of): | ||||
|   end_datetime = vm["deleted"] if vm["deleted"] else as_of | ||||
|   days = float((end_datetime - vm["created"]).total_seconds())/float(60*60*24) | ||||
|   if days < 1: | ||||
|     days = float(1) | ||||
|   return days / average_number_of_days_in_a_month | ||||
|  | ||||
| def get_account_balance(vms, payments, as_of): | ||||
|  | ||||
|   vm_cost_dollars = 0.0 | ||||
|   for vm in vms: | ||||
|     end_datetime = vm["deleted"] if vm["deleted"] else as_of | ||||
|     vm_months = ( end_datetime - vm["created"] ).days / average_number_of_days_in_a_month | ||||
|     vm_months = get_vm_months_float(vm, as_of) | ||||
|     vm_cost_dollars += vm_months * float(vm["dollars_per_month"]) | ||||
|  | ||||
|   payment_dollars_total = float( sum(map(lambda x: 0 if x["invalidated"] else x["dollars"], payments)) ) | ||||
| @ -277,6 +290,12 @@ def get_account_balance(vms, payments, as_of): | ||||
| @bp.route("/account-balance") | ||||
| @account_required | ||||
| def account_balance(): | ||||
|  | ||||
|   payment_sessions = get_model().list_payment_sessions_for_account(session['account']) | ||||
|   for payment_session in payment_sessions: | ||||
|     if payment_session['type'] == 'btcpay': | ||||
|       poll_btcpay_session(payment_session['id']) | ||||
|  | ||||
|   payments = get_payments() | ||||
|   vms = get_vms() | ||||
|   balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7))  | ||||
| @ -298,9 +317,7 @@ def account_balance(): | ||||
|   vms_billed = list() | ||||
|  | ||||
|   for vm in get_vms(): | ||||
|     end_datetime = vm["deleted"] if vm["deleted"] else datetime.utcnow() | ||||
|  | ||||
|     vm_months = (end_datetime - vm["created"]).days / average_number_of_days_in_a_month | ||||
|     vm_months = get_vm_months_float(vm, datetime.utcnow()) | ||||
|     vms_billed.append(dict( | ||||
|       id=vm["id"],  | ||||
|       dollars_per_month=vm["dollars_per_month"], | ||||
|  | ||||
| @ -164,6 +164,17 @@ class DBModel: | ||||
|     ) | ||||
|     self.connection.commit() | ||||
|  | ||||
|   def list_payment_sessions_for_account(self, email): | ||||
|     self.cursor.execute("""  | ||||
|       SELECT id, type, dollars, created | ||||
|       FROM payment_sessions WHERE email = %s""",  | ||||
|       (email, ) | ||||
|     ) | ||||
|     return list(map( | ||||
|       lambda x: dict(id=x[0], type=x[1], dollars=x[2], created=x[3]),  | ||||
|       self.cursor.fetchall() | ||||
|     )) | ||||
|  | ||||
|   def consume_payment_session(self, payment_type, id, dollars): | ||||
|     self.cursor.execute("SELECT email, dollars FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type)) | ||||
|     row = self.cursor.fetchone() | ||||
|  | ||||
| @ -86,7 +86,7 @@ def get_plot_bytes(metric, capsulid, duration, size): | ||||
|   # Prometheus queries to pull metrics for VMs | ||||
|   metric_queries = dict( | ||||
|     cpu=f"irate(libvirtd_domain_info_cpu_time_seconds_total{{domain='{capsulid}'}}[30s])", | ||||
|     memory=f"libvirtd_domain_info_memory_usage_bytes{{domain='{capsulid}'}}", | ||||
|     memory=f"libvirtd_domain_info_maximum_memory_bytes{{domain='{capsulid}'}}-libvirtd_domain_info_memory_unused_bytes{{domain='{capsulid}'}}", | ||||
|     network_in=f"rate(libvirtd_domain_interface_stats_receive_bytes_total{{domain='{capsulid}'}}[{interval_seconds}s])", | ||||
|     network_out=f"rate(libvirtd_domain_interface_stats_transmit_bytes_total{{domain='{capsulid}'}}[{interval_seconds}s])", | ||||
|     disk=f"rate(libvirtd_domain_block_stats_read_bytes_total{{domain='{capsulid}'}}[{interval_seconds}s])%2Brate(libvirtd_domain_block_stats_write_bytes_total{{domain='{capsulid}'}}[{interval_seconds}s])", | ||||
|  | ||||
| @ -59,6 +59,9 @@ def btcpay_payment(): | ||||
|         redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance", | ||||
|         notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook" | ||||
|       )) | ||||
|  | ||||
|       current_app.logger.info(f"created btcpay invoice: {invoice}") | ||||
|  | ||||
|       # print(invoice) | ||||
|       invoice_id = invoice["id"] | ||||
|  | ||||
| @ -74,22 +77,17 @@ def btcpay_payment(): | ||||
|   return render_template("btcpay.html", invoice_id=invoice_id) | ||||
|  | ||||
|  | ||||
| @bp.route("/btcpay/webhook", methods=("POST",)) | ||||
| def btcpay_webhook(): | ||||
|    | ||||
|   # IMPORTANT! there is no signature or credential for the data sent into this webhook :facepalm: | ||||
|   # its just a notification, thats all. | ||||
|   request_data = json.loads(request.data) | ||||
|   invoice_id = request_data['id'] | ||||
|  | ||||
| def poll_btcpay_session(invoice_id): | ||||
|   # so you better make sure to get the invoice directly from the horses mouth!  | ||||
|   invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id) | ||||
|  | ||||
|   if invoice['currency'] != "USD": | ||||
|     abort(400, "invalid currency") | ||||
|     return [400, "invalid currency"] | ||||
|    | ||||
|   dollars = invoice['price'] | ||||
|  | ||||
|   current_app.logger.info(f"got btcpay webhook with invoice_id={invoice_id}, status={invoice['status']} dollars={dollars}") | ||||
|  | ||||
|   if invoice['status'] == "paid" or invoice['status'] == "confirmed" or invoice['status'] == "complete": | ||||
|     success_account = get_model().consume_payment_session("btcpay", invoice_id, dollars) | ||||
|          | ||||
| @ -101,7 +99,21 @@ def btcpay_webhook(): | ||||
|   elif invoice['status'] == "expired" or invoice['status'] == "invalid": | ||||
|     get_model().btcpay_invoice_resolved(invoice_id, False) | ||||
|    | ||||
|   return {"msg": "ok"}, 200 | ||||
|   return [200, "ok"] | ||||
|  | ||||
| @bp.route("/btcpay/webhook", methods=("POST",)) | ||||
| def btcpay_webhook(): | ||||
|    | ||||
|   current_app.logger.info(f"got btcpay webhook") | ||||
|  | ||||
|   # IMPORTANT! there is no signature or credential for the data sent into this webhook :facepalm: | ||||
|   # its just a notification, thats all. | ||||
|   request_data = json.loads(request.data) | ||||
|   invoice_id = request_data['id'] | ||||
|  | ||||
|   result = poll_btcpay_session(invoice_id) | ||||
|  | ||||
|   abort(result[0], result[1]) | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,3 +1,7 @@ | ||||
| DROP TABLE unresolved_btcpay_invoices; | ||||
|  | ||||
| DROP TABLE payment_sessions; | ||||
|  | ||||
| DROP TABLE payments; | ||||
|  | ||||
| DROP TABLE login_tokens; | ||||
|  | ||||
| @ -98,14 +98,4 @@ VALUES  ('f1-s',    5.33,   512, 1,   500), | ||||
|         ('f1-xx',  29.66,  8192, 4,  8000), | ||||
|         ('f1-xxx', 57.58, 16384, 8, 16000); | ||||
|  | ||||
| -- this is test data to be removed later  | ||||
| INSERT INTO accounts (email) | ||||
| VALUES  ('forest.n.johnson@gmail.com'); | ||||
|  | ||||
| INSERT INTO payments (email, dollars, created) | ||||
| VALUES  ('forest.n.johnson@gmail.com', 20.00, TO_TIMESTAMP('2020-04-05','YYYY-MM-DD')); | ||||
|  | ||||
| INSERT INTO vms (id, email, os, size, created) | ||||
| VALUES  ('capsul-yi9ffqbjly', 'forest.n.johnson@gmail.com', 'alpine311', 'f1-xx', TO_TIMESTAMP('2020-04-19','YYYY-MM-DD')); | ||||
|  | ||||
| UPDATE schemaversion SET version = 2; | ||||
| @ -45,6 +45,15 @@ a:hover, a:active, a:visited { | ||||
|   padding: 1em; | ||||
| } | ||||
|  | ||||
| .flash.green { | ||||
|   color: rgb(8, 173, 137); | ||||
|   border-color: rgb(8, 173, 137); | ||||
| } | ||||
|  | ||||
| .display-none { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| h1, h2, h3, h4, h5 { | ||||
|   font-size:calc(0.40rem + 1vmin); | ||||
|   margin: initial; | ||||
| @ -63,6 +72,8 @@ main { | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| .full-margin { | ||||
|   width: 100%; | ||||
|   margin: 3rem 0; | ||||
|  | ||||
| @ -39,6 +39,7 @@ | ||||
| {% for message in get_flashed_messages() %} | ||||
|   <div class="flash">{{ message }}</div> | ||||
| {% endfor %} | ||||
| {% block custom_flash %}{% endblock %} | ||||
| <main> | ||||
|   {% block content %}{% endblock %} | ||||
| </main> | ||||
|  | ||||
| @ -2,6 +2,12 @@ | ||||
|  | ||||
| {% block title %}Capsuls{% endblock %} | ||||
|  | ||||
| {% block custom_flash %} | ||||
| {% if created  %}  | ||||
|   <div class="flash green">{{ created }} successfully created!</div> | ||||
| {% endif %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="row third-margin"> | ||||
|   <h1>Capsuls</h1> | ||||
|  | ||||
| @ -19,6 +19,7 @@ | ||||
|    <li>2020-04-17: OpenBSD support added</li> | ||||
|    <li>2020-04-26: Support link added</li> | ||||
|    <li>2020-05-04: Simplified payment page</li> | ||||
|    <li>2020-05-16: Beta version of new Capsul web application</li> | ||||
|  </ul>            | ||||
| </p> | ||||
| {% endblock %} | ||||
|  | ||||
| @ -6,21 +6,10 @@ | ||||
| <div class="row third-margin"> | ||||
|   <h1>Create Capsul</h1> | ||||
| </div> | ||||
| <div class="row third-margin"><div> | ||||
|    | ||||
| {% if created_os  %}  | ||||
|   <p> | ||||
|     Your Capsul was successfully created! You should already see it listed on the  | ||||
|     <a href="/console/">Capsuls page</a>, but it may not have obtained an IP address yet. | ||||
|     Its IP address should become visible once the machine has booted and taken a DHCP lease. | ||||
|   </p>  | ||||
|   {% if created_os == 'debian10' %} | ||||
|     <p> | ||||
|       Note: because Debian delays fully booting until after entropy has been generated, Debian Capsuls  | ||||
|       may take an extra-long time to obtain an IP address, like up to 10 minutes. Be patient. | ||||
|     </p>  | ||||
|   {% endif %} | ||||
| {% else %} | ||||
| <div class="row third-margin"> | ||||
|   <div> | ||||
|      | ||||
|  | ||||
|   {% if cant_afford  %}  | ||||
|     <p> | ||||
|       Your account does not have sufficient funds to create a Capsul. | ||||
| @ -36,7 +25,7 @@ | ||||
|     <pre> | ||||
|       CAPSUL SIZES | ||||
|       ============ | ||||
|       type     monthly   cpus  mem     ssd   net* | ||||
|       type     monthly*  cpus  mem     ssd   net* | ||||
|       -----    -------   ----  ---     ---   ---  | ||||
|       f1-s     $5.33     1     512M    25G   .5TB | ||||
|       f1-m     $7.16     1     1024M   25G   1TB  | ||||
| @ -46,6 +35,7 @@ | ||||
|       f1-xxx   $57.58    8     16G     25G   16TB | ||||
|        | ||||
|       * net is calculated as a per-month average | ||||
|       * vms are billed for a minimum of 24 hours upon creation | ||||
|       * all VMs come standard with one public IPv4 addr</pre> | ||||
|     <pre> | ||||
|       Your <a href="/console/account-balance">account balance</a>: ${{ account_balance }} | ||||
| @ -79,14 +69,24 @@ | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="row justify-end"> | ||||
|         <input type="submit" value="Create"> | ||||
|         <input id="submit-button" type="submit" value="Create"> | ||||
|         <span  id="submit-button-clicked" class="display-none">..Creating...</span> | ||||
|       </div> | ||||
|       <script> | ||||
|         window.addEventListener('DOMContentLoaded', function(event) { | ||||
|             var submitButton = document.getElementById('submit-button'); | ||||
|             var submitButtonClicked = document.getElementById('submit-button-clicked'); | ||||
|             document.getElementById('submit-button').onclick = function() { | ||||
|               submitButton.className = "display-none"; | ||||
|               submitButtonClicked.className = "waiting-pulse"; | ||||
|             } | ||||
|         }); | ||||
|       </script> | ||||
|     </form> | ||||
|   </div> | ||||
|   {% endif %} | ||||
|  | ||||
| {% endif %} | ||||
| </div></div> | ||||
|   </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block subcontent %} | ||||
|  | ||||
		Reference in New Issue
	
	Block a user