from base64 import b64encode from datetime import datetime, timedelta import json import re import sys from flask import Blueprint from flask import flash from flask import current_app 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 werkzeug.exceptions import abort from nanoid import generate from capsulflask.metrics import durations as metric_durations from capsulflask.auth import account_required from capsulflask.db import get_model from capsulflask.shared import my_exec_info_message from capsulflask.payment import poll_btcpay_session from capsulflask import cli bp = Blueprint("console", __name__, url_prefix="/console") def make_capsul_id(): letters_n_nummers = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10) return f"capsul-{letters_n_nummers}" def double_check_capsul_address(id, ipv4, ipv6, get_ssh_host_keys): try: result = current_app.config["HUB_MODEL"].get(id, get_ssh_host_keys) if result != None and result.ipv4 != None and result.ipv4 != ipv4: get_model().update_vm_ipv4(email=session["account"], id=id, ipv4=result.ipv4) if result != None and result.ipv6 != None and result.ipv6 != ipv6: get_model().update_vm_ipv6(email=session["account"], id=id, ipv6=result.ipv6) if result != None and result.ssh_host_keys != None and get_ssh_host_keys: get_model().update_vm_ssh_host_keys(email=session["account"], id=id, ssh_host_keys=result.ssh_host_keys) except: current_app.logger.error(f""" the virtualization model threw an error in double_check_capsul_address of {id}: {my_exec_info_message(sys.exc_info())}""" ) return None return result @bp.route("/") @account_required def index(): vms = get_vms() vms = list(filter(lambda x: not x['deleted'], 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... for vm in vms: result = double_check_capsul_address(vm["id"], vm["ipv4"], vm["ipv6"], False) if result is not None: vm["ipv4"] = result.ipv4 vm["ipv6"] = result.ipv6 vm["state"] = result.state else: vm["state"] = "unknown" mappedVms = [] for vm in vms: ip_display = {} ip_display_class = {} for af in ['ipv4', 'ipv6']: ip_display[af] = vm[af] ip_display_class[af] = "ok" if not ip_display[af]: if vm["state"] == "running": ip_display[af] = "..booting.." ip_display_class[af] = "waiting-pulse" else: ip_display[af] = "unknown" ip_display_class[af] = "yellow" mappedVms.append(dict( id=vm['id'], size=vm['size'], state=vm['state'], ipv4=ip_display['ipv4'], ipv4_status=ip_display_class['ipv4'], ipv6=ip_display['ipv6'], ipv6_status=ip_display_class['ipv6'], os=vm['os'], created=vm['created'].strftime("%b %d %Y") )) return render_template("capsuls.html", vms=mappedVms, has_vms=len(vms) > 0, created=created) @bp.route("/", methods=("GET", "POST")) @account_required def detail(id): duration=request.args.get('duration') if not duration: duration = "5m" vm = get_model().get_vm_detail(email=session["account"], id=id) if vm is None: return abort(404, f"{id} doesn't exist.") vm['ssh_username'] = current_app.config['SSH_USERNAME'] if vm['deleted']: return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True) vm["created"] = vm['created'].strftime("%b %d %Y %H:%M") vm["ssh_authorized_keys"] = ", ".join(vm["ssh_authorized_keys"]) if len(vm["ssh_authorized_keys"]) > 0 else "" if request.method == "POST": if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: return abort(418, f"u want tea") if 'action' not in request.form: return abort(400, "action is required") if request.form['action'] == "start": current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="start") vm["state"] = "starting" return render_template("capsul-detail.html", vm=vm) elif request.form['action'] == "delete": if 'are_you_sure' not in request.form or not request.form['are_you_sure']: return render_template( "capsul-detail.html", csrf_token = session["csrf-token"], vm=vm, delete=True ) else: current_app.logger.info(f"deleting {vm['id']} per user request ({session['account']})") current_app.config["HUB_MODEL"].destroy(email=session['account'], id=id) get_model().delete_vm(email=session['account'], id=id) return render_template("capsul-detail.html", vm=vm, deleted=True) elif request.form['action'] == "force-stop": if 'are_you_sure' not in request.form or not request.form['are_you_sure']: return render_template( "capsul-detail.html", csrf_token = session["csrf-token"], vm=vm, force_stop=True, ) else: current_app.logger.info(f"force stopping {vm['id']} per user request ({session['account']})") current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="force-stop") vm["state"] = "stopped" return render_template( "capsul-detail.html", csrf_token = session["csrf-token"], vm=vm, durations=list(map(lambda x: x.strip("_"), metric_durations.keys())), duration=duration ) else: return abort(400, "action must be either delete, force-stop, or start") else: needs_ssh_host_keys = "ssh_host_keys" not in vm or len(vm["ssh_host_keys"]) == 0 vm_from_virt_model = double_check_capsul_address(vm["id"], vm["ipv4"], vm['ipv6'], needs_ssh_host_keys) if vm_from_virt_model is not None: vm["ipv4"] = vm_from_virt_model.ipv4 vm["ipv6"] = vm_from_virt_model.ipv6 vm["state"] = vm_from_virt_model.state if needs_ssh_host_keys: vm["ssh_host_keys"] = vm_from_virt_model.ssh_host_keys else: vm["state"] = "unknown" if vm["state"] == "running" and not vm["ipv4"]: vm["state"] = "starting" return render_template( "capsul-detail.html", csrf_token = session["csrf-token"], vm=vm, durations=list(map(lambda x: x.strip("_"), metric_durations.keys())), duration=duration ) def _create(email, vm_sizes, operating_systems, public_keys_for_account, affordable_vm_sizes, server_data): errors = list() size = server_data.get("size") os = server_data.get("os") posted_keys_count = int(server_data.get("ssh_authorized_key_count")) if not size: errors.append("Size is required") elif size not in vm_sizes: errors.append(f"Invalid size {size}") elif size not in affordable_vm_sizes: errors.append(f"Your account must have enough credit to run an {size} for 1 month before you will be allowed to create it") if not os: errors.append("OS is required") elif os not in operating_systems: errors.append(f"Invalid os {os}") posted_keys = list() if posted_keys_count > 1000: errors.append("something went wrong with ssh keys") else: for i in range(0, posted_keys_count): if f"ssh_key_{i}" in server_data: posted_name = server_data.get(f"ssh_key_{i}") key = None for x in public_keys_for_account: if x['name'] == posted_name: key = x if key: posted_keys.append(key) else: errors.append(f"SSH Key \"{posted_name}\" doesn't exist") if len(posted_keys) == 0: errors.append("At least one SSH Public Key is required") capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable( vm_sizes[size]['memory_mb']*1024*1024 ) if not capacity_avaliable: errors.append(""" host(s) at capacity. no capsuls can be created at this time. sorry. """) if len(errors) == 0: id = make_capsul_id() current_app.config["HUB_MODEL"].create( email = email, id=id, os=os, size=size, template_image_file_name=operating_systems[os]['template_image_file_name'], vcpus=vm_sizes[size]['vcpus'], memory_mb=vm_sizes[size]['memory_mb'], ssh_authorized_keys=list(map(lambda x: dict(name=x['name'], content=x['content']), posted_keys)) ) return id, errors return None, errors @bp.route("/create", methods=("GET", "POST")) @account_required def create(): vm_sizes = get_model().vm_sizes_dict() operating_systems = get_model().operating_systems_dict() public_keys_for_account = get_model().list_ssh_public_keys_for_account(session["account"]) account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow()) capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024) errors = list() affordable_vm_sizes = dict() for key, vm_size in vm_sizes.items(): # if a user deposits $7.50 and then creates an f1-s vm which costs 7.50 a month, # then they have to delete the vm and re-create it, they will not be able to, they will have to pay again. # so for UX it makes a lot of sense to give a small margin of 25 cents for usability sake if vm_size["dollars_per_month"] <= account_balance+0.25: affordable_vm_sizes[key] = vm_size if request.method == "POST": if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: return abort(418, f"u want tea") id, errors = _create( session['account'], vm_sizes, operating_systems, public_keys_for_account, affordable_vm_sizes, request.form) if len(errors) == 0: return redirect(f"{url_for('console.index')}?created={id}") for error in errors: flash(error) if not capacity_avaliable: current_app.logger.warning(f"when capsul capacity is restored, send an email to {session['account']}") return render_template( "create-capsul.html", csrf_token = session["csrf-token"], capacity_avaliable=capacity_avaliable, account_balance=format(account_balance, '.2f'), ssh_authorized_keys=public_keys_for_account, ssh_authorized_key_count=len(public_keys_for_account), no_ssh_public_keys=len(public_keys_for_account) == 0, operating_systems=operating_systems, cant_afford=len(affordable_vm_sizes) == 0, vm_sizes=affordable_vm_sizes ) @bp.route("/keys", methods=("GET", "POST")) @account_required def ssh_api_keys(): errors = list() token = None if request.method == "POST": if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: return abort(418, f"u want tea") action = request.form["action"] if action == 'upload_ssh_key': content = None content = request.form["content"].replace("\r", " ").replace("\n", " ").strip() name = request.form["name"] if not name or len(name.strip()) < 1: parts = re.split(" +", content) if len(parts) > 2 and len(parts[2].strip()) > 0: name = parts[2].strip() else: name = parts[0].strip() else: errors.append("Name is required") if not re.match(r"^[0-9A-Za-z_@:. -]+$", name): errors.append(f"Key name '{name}' must match \"^[0-9A-Za-z_@:. -]+$\"") if not content or len(content.strip()) < 1: errors.append("Content is required") else: if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$", content): errors.append("Content must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$\"") if get_model().ssh_public_key_name_exists(session["account"], name): errors.append("A key with that name already exists") if len(errors) == 0: get_model().create_ssh_public_key(session["account"], name, content) elif action == "delete_ssh_key": get_model().delete_ssh_public_key(session["account"], name) elif action == "generate_api_token": name = request.form["name"] if name == '': name = datetime.utcnow().strftime('%y-%m-%d %H:%M:%S') token = b64encode( get_model().generate_api_token(session["account"], name).encode('utf-8') ).decode('utf-8') elif action == "delete_api_token": get_model().delete_api_token(session["account"], request.form["id"]) for error in errors: flash(error) ssh_keys_list=list(map( lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"), get_model().list_ssh_public_keys_for_account(session["account"]) )) api_tokens_list = get_model().list_api_tokens(session["account"]) return render_template( "keys.html", csrf_token = session["csrf-token"], api_tokens=api_tokens_list, ssh_public_keys=ssh_keys_list, generated_api_token=token, ) def get_vms(): if 'user_vms' not in g: g.user_vms = get_model().list_vms_for_account(session["account"]) return g.user_vms def get_payments(): if 'user_payments' not in g: g.user_payments = get_model().list_payments_for_account(session["account"]) return g.user_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: 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)) ) return payment_dollars_total - vm_cost_dollars @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)) balance_1d = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=1)) balance_now = get_account_balance(vms, payments, datetime.utcnow()) warning_index = -1 warning_text = "" warnings = cli.get_warnings_list() for i in range(0, len(warnings)): if warnings[i]['get_active'](balance_1w, balance_1d, balance_now): warning_index = i if warning_index > -1: pluralize_capsul = "s" if len(vms) > 1 else "" warning_id = warnings[warning_index]['id'] warning_text = cli.get_warning_headline(warning_id, pluralize_capsul) vms_billed = list() for vm in get_vms(): vm_months = get_vm_months_float(vm, datetime.utcnow()) vms_billed.append(dict( id=vm["id"], dollars_per_month=vm["dollars_per_month"], created=vm["created"].strftime("%b %d %Y"), deleted=vm["deleted"].strftime("%b %d %Y") if vm["deleted"] else "N/A", months=format(vm_months, '.3f'), dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f') )) return render_template( "account-balance.html", has_vms=len(vms_billed)>0, vms_billed=vms_billed, warning_text=warning_text, btcpay_enabled=current_app.config["BTCPAY_ENABLED"], payments=list(map( lambda x: dict( dollars=x["dollars"], class_name="invalidated" if x["invalidated"] else "", created=x["created"].strftime("%b %d %Y") ), payments )), has_payments=len(payments)>0, account_balance=format(balance_now, '.2f') )