Merge branch 'master' of giit.cyberia.club:~forest/capsul-flask

This commit is contained in:
j3s 2020-05-17 14:19:15 -05:00
commit 8962345fd2
15 changed files with 120 additions and 56 deletions

View File

@ -28,6 +28,7 @@ stripe = "*"
matplotlib = "*" matplotlib = "*"
requests = "*" requests = "*"
python-dotenv = "*" python-dotenv = "*"
ecdsa = "*"
[dev-packages] [dev-packages]

10
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "4f61804f9405ff8f66e41739aeac24a2577f7b64f7d08b93459b568528c4f494" "sha256": "89f5b91fef8e0029cbecf7f83ce1fdc64a64f295161c8c63bfe5940e31d466e1"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -60,6 +60,14 @@
], ],
"version": "==0.10.0" "version": "==0.10.0"
}, },
"ecdsa": {
"hashes": [
"sha256:867ec9cf6df0b03addc8ef66b56359643cb5d0c1dc329df76ba7ecfe256c8061",
"sha256:8f12ac317f8a1318efa75757ef0a651abe12e51fc1af8838fb91079445227277"
],
"index": "pypi",
"version": "==0.15"
},
"flask": { "flask": {
"hashes": [ "hashes": [
"sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060",

View File

@ -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` 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----- BTCPAY_PRIVATE_KEY='-----BEGIN EC PRIVATE KEY-----\nEXAMPLEIArx/EXAMPLEKH23EXAMPLEsYXEXAMPLE5qdEXAMPLEcFHoAcEXAMPLEK\noUQDQgAEnWs47PT8+ihhzyvXX6/yYMAWWODluRTR2Ix6ZY7Z+MV7v0W1maJzqeqq\nNQ+cpBvPDbyrDk9+Uf/sEaRCma094g==\n-----END EC PRIVATE KEY-----'
EXAMPLEIArx/EXAMPLEKH23EXAMPLEsYXEXAMPLE5qdEXAMPLEcFHoAcEXAMPLEK
oUQDQgAEnWs47PT8+ihhzyvXX6/yYMAWWODluRTR2Ix6ZY7Z+MV7v0W1maJzqeqq
NQ+cpBvPDbyrDk9+Uf/sEaRCma094g==
-----END EC PRIVATE KEY-----'
``` ```

View File

@ -71,6 +71,10 @@ def magiclink(token):
session["account"] = email session["account"] = email
return redirect(url_for("console.index")) return redirect(url_for("console.index"))
else: 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.") abort(404, f"Token {token} doesn't exist or has already been used.")
@bp.route("/logout") @bp.route("/logout")

View File

@ -8,6 +8,8 @@ from flask import g
from flask import request from flask import request
from flask import session from flask import session
from flask import render_template from flask import render_template
from flask import redirect
from flask import url_for
from flask_mail import Message from flask_mail import Message
from werkzeug.exceptions import abort from werkzeug.exceptions import abort
from nanoid import generate from nanoid import generate
@ -15,6 +17,7 @@ from nanoid import generate
from capsulflask.metrics import durations as metric_durations from capsulflask.metrics import durations as metric_durations
from capsulflask.auth import account_required from capsulflask.auth import account_required
from capsulflask.db import get_model, my_exec_info_message from capsulflask.db import get_model, my_exec_info_message
from capsulflask.payment import poll_btcpay_session
from capsulflask import cli from capsulflask import cli
bp = Blueprint("console", __name__, url_prefix="/console") bp = Blueprint("console", __name__, url_prefix="/console")
@ -41,6 +44,11 @@ def double_check_capsul_address(id, ipv4):
@account_required @account_required
def index(): def index():
vms = get_vms() 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 # 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... # 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)) 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")) @bp.route("/<string:id>", methods=("GET", "POST"))
@account_required @account_required
@ -110,7 +118,6 @@ def create():
account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow()) account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow())
capacity_avaliable = current_app.config["VIRTUALIZATION_MODEL"].capacity_avaliable(512*1024*1024) capacity_avaliable = current_app.config["VIRTUALIZATION_MODEL"].capacity_avaliable(512*1024*1024)
errors = list() errors = list()
created_os = None
if request.method == "POST": if request.method == "POST":
@ -171,7 +178,8 @@ def create():
memory_mb=vm_sizes[size]['memory_mb'], memory_mb=vm_sizes[size]['memory_mb'],
ssh_public_keys=list(map(lambda x: x["content"], posted_keys)) 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() affordable_vm_sizes = dict()
for key, vm_size in vm_sizes.items(): for key, vm_size in vm_sizes.items():
@ -186,7 +194,6 @@ def create():
return render_template( return render_template(
"create-capsul.html", "create-capsul.html",
created_os=created_os,
capacity_avaliable=capacity_avaliable, capacity_avaliable=capacity_avaliable,
account_balance=format(account_balance, '.2f'), account_balance=format(account_balance, '.2f'),
ssh_public_keys=ssh_public_keys, ssh_public_keys=ssh_public_keys,
@ -262,12 +269,18 @@ def get_payments():
average_number_of_days_in_a_month = 30.44 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): def get_account_balance(vms, payments, as_of):
vm_cost_dollars = 0.0 vm_cost_dollars = 0.0
for vm in vms: for vm in vms:
end_datetime = vm["deleted"] if vm["deleted"] else as_of vm_months = get_vm_months_float(vm, as_of)
vm_months = ( end_datetime - vm["created"] ).days / average_number_of_days_in_a_month
vm_cost_dollars += vm_months * float(vm["dollars_per_month"]) 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)) ) 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") @bp.route("/account-balance")
@account_required @account_required
def account_balance(): 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() payments = get_payments()
vms = get_vms() vms = get_vms()
balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7)) balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7))
@ -298,9 +317,7 @@ def account_balance():
vms_billed = list() vms_billed = list()
for vm in get_vms(): for vm in get_vms():
end_datetime = vm["deleted"] if vm["deleted"] else datetime.utcnow() vm_months = get_vm_months_float(vm, datetime.utcnow())
vm_months = (end_datetime - vm["created"]).days / average_number_of_days_in_a_month
vms_billed.append(dict( vms_billed.append(dict(
id=vm["id"], id=vm["id"],
dollars_per_month=vm["dollars_per_month"], dollars_per_month=vm["dollars_per_month"],

View File

@ -164,6 +164,17 @@ class DBModel:
) )
self.connection.commit() 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): 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)) self.cursor.execute("SELECT email, dollars FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type))
row = self.cursor.fetchone() row = self.cursor.fetchone()

View File

@ -86,7 +86,7 @@ def get_plot_bytes(metric, capsulid, duration, size):
# Prometheus queries to pull metrics for VMs # Prometheus queries to pull metrics for VMs
metric_queries = dict( metric_queries = dict(
cpu=f"irate(libvirtd_domain_info_cpu_time_seconds_total{{domain='{capsulid}'}}[30s])", 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_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])", 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])", 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])",

View File

@ -59,6 +59,9 @@ def btcpay_payment():
redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance", redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance",
notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook" notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook"
)) ))
current_app.logger.info(f"created btcpay invoice: {invoice}")
# print(invoice) # print(invoice)
invoice_id = invoice["id"] invoice_id = invoice["id"]
@ -74,22 +77,17 @@ def btcpay_payment():
return render_template("btcpay.html", invoice_id=invoice_id) return render_template("btcpay.html", invoice_id=invoice_id)
@bp.route("/btcpay/webhook", methods=("POST",)) def poll_btcpay_session(invoice_id):
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']
# so you better make sure to get the invoice directly from the horses mouth! # so you better make sure to get the invoice directly from the horses mouth!
invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id) invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id)
if invoice['currency'] != "USD": if invoice['currency'] != "USD":
abort(400, "invalid currency") return [400, "invalid currency"]
dollars = invoice['price'] 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": if invoice['status'] == "paid" or invoice['status'] == "confirmed" or invoice['status'] == "complete":
success_account = get_model().consume_payment_session("btcpay", invoice_id, dollars) 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": elif invoice['status'] == "expired" or invoice['status'] == "invalid":
get_model().btcpay_invoice_resolved(invoice_id, False) 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])

View File

@ -1,3 +1,7 @@
DROP TABLE unresolved_btcpay_invoices;
DROP TABLE payment_sessions;
DROP TABLE payments; DROP TABLE payments;
DROP TABLE login_tokens; DROP TABLE login_tokens;

View File

@ -98,14 +98,4 @@ VALUES ('f1-s', 5.33, 512, 1, 500),
('f1-xx', 29.66, 8192, 4, 8000), ('f1-xx', 29.66, 8192, 4, 8000),
('f1-xxx', 57.58, 16384, 8, 16000); ('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; UPDATE schemaversion SET version = 2;

View File

@ -45,6 +45,15 @@ a:hover, a:active, a:visited {
padding: 1em; padding: 1em;
} }
.flash.green {
color: rgb(8, 173, 137);
border-color: rgb(8, 173, 137);
}
.display-none {
display: none;
}
h1, h2, h3, h4, h5 { h1, h2, h3, h4, h5 {
font-size:calc(0.40rem + 1vmin); font-size:calc(0.40rem + 1vmin);
margin: initial; margin: initial;
@ -63,6 +72,8 @@ main {
align-items: center; align-items: center;
} }
.full-margin { .full-margin {
width: 100%; width: 100%;
margin: 3rem 0; margin: 3rem 0;

View File

@ -39,6 +39,7 @@
{% for message in get_flashed_messages() %} {% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div> <div class="flash">{{ message }}</div>
{% endfor %} {% endfor %}
{% block custom_flash %}{% endblock %}
<main> <main>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>

View File

@ -2,6 +2,12 @@
{% block title %}Capsuls{% endblock %} {% block title %}Capsuls{% endblock %}
{% block custom_flash %}
{% if created %}
<div class="flash green">{{ created }} successfully created!</div>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div class="row third-margin"> <div class="row third-margin">
<h1>Capsuls</h1> <h1>Capsuls</h1>

View File

@ -19,6 +19,7 @@
<li>2020-04-17: OpenBSD support added</li> <li>2020-04-17: OpenBSD support added</li>
<li>2020-04-26: Support link added</li> <li>2020-04-26: Support link added</li>
<li>2020-05-04: Simplified payment page</li> <li>2020-05-04: Simplified payment page</li>
<li>2020-05-16: Beta version of new Capsul web application</li>
</ul> </ul>
</p> </p>
{% endblock %} {% endblock %}

View File

@ -6,21 +6,10 @@
<div class="row third-margin"> <div class="row third-margin">
<h1>Create Capsul</h1> <h1>Create Capsul</h1>
</div> </div>
<div class="row third-margin"><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 %}
{% if cant_afford %} {% if cant_afford %}
<p> <p>
Your account does not have sufficient funds to create a Capsul. Your account does not have sufficient funds to create a Capsul.
@ -36,7 +25,7 @@
<pre> <pre>
CAPSUL SIZES CAPSUL SIZES
============ ============
type monthly cpus mem ssd net* type monthly* cpus mem ssd net*
----- ------- ---- --- --- --- ----- ------- ---- --- --- ---
f1-s $5.33 1 512M 25G .5TB f1-s $5.33 1 512M 25G .5TB
f1-m $7.16 1 1024M 25G 1TB f1-m $7.16 1 1024M 25G 1TB
@ -46,6 +35,7 @@
f1-xxx $57.58 8 16G 25G 16TB f1-xxx $57.58 8 16G 25G 16TB
* net is calculated as a per-month average * 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> * all VMs come standard with one public IPv4 addr</pre>
<pre> <pre>
Your <a href="/console/account-balance">account balance</a>: ${{ account_balance }} Your <a href="/console/account-balance">account balance</a>: ${{ account_balance }}
@ -79,14 +69,24 @@
</div> </div>
</div> </div>
<div class="row justify-end"> <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> </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> </form>
</div>
{% endif %} {% endif %}
{% endif %} </div>
</div></div> </div>
{% endblock %} {% endblock %}
{% block subcontent %} {% block subcontent %}