diff --git a/Pipfile b/Pipfile index e0edea9..40866b6 100644 --- a/Pipfile +++ b/Pipfile @@ -28,6 +28,7 @@ stripe = "*" matplotlib = "*" requests = "*" python-dotenv = "*" +ecdsa = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 25156ce..e17b127 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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", diff --git a/README.md b/README.md index f51317c..4c59d52 100644 --- a/README.md +++ b/README.md @@ -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-----' ``` \ No newline at end of file diff --git a/capsulflask/auth.py b/capsulflask/auth.py index cda0942..5509702 100644 --- a/capsulflask/auth.py +++ b/capsulflask/auth.py @@ -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") diff --git a/capsulflask/console.py b/capsulflask/console.py index 80d2354..16e0d0e 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -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("/", 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"], diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 02a6249..57c86a0 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -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() diff --git a/capsulflask/metrics.py b/capsulflask/metrics.py index 1e71b0a..fc33b61 100644 --- a/capsulflask/metrics.py +++ b/capsulflask/metrics.py @@ -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])", diff --git a/capsulflask/payment.py b/capsulflask/payment.py index 9159d9a..8bbb433 100644 --- a/capsulflask/payment.py +++ b/capsulflask/payment.py @@ -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]) diff --git a/capsulflask/schema_migrations/02_down_accounts_vms_etc.sql b/capsulflask/schema_migrations/02_down_accounts_vms_etc.sql index 195be9b..9910c89 100644 --- a/capsulflask/schema_migrations/02_down_accounts_vms_etc.sql +++ b/capsulflask/schema_migrations/02_down_accounts_vms_etc.sql @@ -1,3 +1,7 @@ +DROP TABLE unresolved_btcpay_invoices; + +DROP TABLE payment_sessions; + DROP TABLE payments; DROP TABLE login_tokens; diff --git a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql index 7f98356..87e8bf8 100644 --- a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql +++ b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql @@ -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; \ No newline at end of file diff --git a/capsulflask/static/style.css b/capsulflask/static/style.css index e496808..8353e1c 100644 --- a/capsulflask/static/style.css +++ b/capsulflask/static/style.css @@ -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; diff --git a/capsulflask/templates/base.html b/capsulflask/templates/base.html index 68408ba..21b4af1 100644 --- a/capsulflask/templates/base.html +++ b/capsulflask/templates/base.html @@ -39,6 +39,7 @@ {% for message in get_flashed_messages() %}
{{ message }}
{% endfor %} +{% block custom_flash %}{% endblock %}
{% block content %}{% endblock %}
diff --git a/capsulflask/templates/capsuls.html b/capsulflask/templates/capsuls.html index ee65a5c..0122089 100644 --- a/capsulflask/templates/capsuls.html +++ b/capsulflask/templates/capsuls.html @@ -2,6 +2,12 @@ {% block title %}Capsuls{% endblock %} +{% block custom_flash %} +{% if created %} +
{{ created }} successfully created!
+{% endif %} +{% endblock %} + {% block content %}

Capsuls

diff --git a/capsulflask/templates/changelog.html b/capsulflask/templates/changelog.html index 89deb19..685bc5d 100644 --- a/capsulflask/templates/changelog.html +++ b/capsulflask/templates/changelog.html @@ -19,6 +19,7 @@
  • 2020-04-17: OpenBSD support added
  • 2020-04-26: Support link added
  • 2020-05-04: Simplified payment page
  • +
  • 2020-05-16: Beta version of new Capsul web application
  • {% endblock %} diff --git a/capsulflask/templates/create-capsul.html b/capsulflask/templates/create-capsul.html index 9e05ae4..bb27042 100644 --- a/capsulflask/templates/create-capsul.html +++ b/capsulflask/templates/create-capsul.html @@ -6,21 +6,10 @@

    Create Capsul

    -
    - -{% if created_os %} -

    - Your Capsul was successfully created! You should already see it listed on the - Capsuls page, 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. -

    - {% if created_os == 'debian10' %} -

    - 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. -

    - {% endif %} -{% else %} +
    +
    + + {% if cant_afford %}

    Your account does not have sufficient funds to create a Capsul. @@ -36,7 +25,7 @@

           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
           Your account balance: ${{ account_balance }}
    @@ -79,14 +69,24 @@
             
    - + +
    + -
    {% endif %} -{% endif %} -
    + + {% endblock %} {% block subcontent %}