26 Commits

Author SHA1 Message Date
6dbe2b5c86 forgot host id 2021-07-21 16:18:43 -05:00
f7461d5a28 default_network and default_ipv4 on mock hub 2021-07-21 16:16:50 -05:00
3wc
9f23638959 Add VMs to the database even with HUB_MODE=mock
Closes ~cyberia/services#83
2021-07-21 22:10:56 +02:00
3wc
8f2becb9ee Fix SyntaxWarning on is not ""
`capsulflask/__init__.py:143: SyntaxWarning: "is not" with a literal. Did you mean "!="?`
2021-07-21 21:51:50 +02:00
f848eda931 Merge pull request 'Disable BTCPay in the interface, if BTCPAY_PRIVATE_KEY is un-set' (#3) from optional-btcpay into master
Reviewed-on: #3
2021-07-21 20:20:40 +02:00
46f49e8d8f Merge pull request 'Auto-generate the pricing table from the database' (#4) from generated-prices into master
Reviewed-on: #4
2021-07-21 19:32:09 +02:00
36329796f0 define BTCPAY_ENABLED based on URL and btcpay key, pass it explicitly 2021-07-21 12:12:41 -05:00
28271ee852 remove class="small" from pricing table cuz normal size fits fine 2021-07-21 11:53:44 -05:00
3wc
7923f3a99f Auto-generate the pricing table from the database 2021-07-21 12:19:28 +02:00
3wc
7ed847251f Don't load /btcpay if BTCPAY_PRIVATE_KEY un-set 2021-07-21 00:22:58 +02:00
3wc
e3a4776a5d Hide the BTCPay link if BTCPAY_PRIVATE_KEY un-set 2021-07-21 00:20:38 +02:00
3wc
357d99cb91 Add load_config_vars context processor..
..to allow accessing config variables in the templates.

This removes the need for adding config variables manually to template
contexts.
2021-07-21 00:19:38 +02:00
b8279d7491 add about ssh link to faq 2021-07-19 18:09:02 -05:00
908d02803f move hardcoding public3 to the right place 2021-07-12 16:10:28 -05:00
6e6bd2b143 fix syntax error 2021-07-12 16:00:37 -05:00
47fbaab403 hardcode network_name=public3 to sidestep phantom dhcp lease issues 2021-07-12 15:59:20 -05:00
06a2bd3a6f add public_ipv4_first_usable_ip, public_ipv4_last_usable_ip 2021-07-12 14:38:56 -05:00
fbe9c7fca4 fix last IP address hanging off the end of the display on admin page 2021-07-12 12:27:07 -05:00
6bdb133153 make admin display work better on phones 2021-07-12 12:23:27 -05:00
b459e56f3a use the provided network_name when creating a capsul 2021-07-12 12:19:56 -05:00
be54117736 migration 17: network_name -> virtual_bridge_name, add network_name col 2021-07-12 12:16:32 -05:00
aaf33a245b point capsul at new public3/virbr3 network 2021-07-12 11:29:37 -05:00
ad9c3476c7 simplify cidr block logic a little bit 2021-07-12 11:29:33 -05:00
fcbea1e29b fixing capsul creation after I broke it with the pre-allocated IP
address changes
2021-07-11 12:18:58 -05:00
a2f2e744e4 MAIL_USE_TLS=False, MAIL_USE_SSL=True defaults 2021-07-11 10:28:47 -05:00
79ef90c380 hub allocate capsul IP addr when the create operation is being claimed
create.sh will now be passed two extra arguments from the web app:

network_name and public_ipv4_address

network_name will be virbr1 or virbr2 or whatever the network is called
and public_ipv4_address will be an ipv4 from that network which is not
currently being used
2021-07-09 17:08:51 -05:00
33 changed files with 511 additions and 423 deletions

View File

@ -1,14 +0,0 @@
---
kind: pipeline
name: publish docker image
steps:
- name: build and publish
image: plugins/docker
settings:
username:
from_secret: docker_reg_username_3wc
password:
from_secret: docker_reg_passwd_3wc
repo: 3wordchant/capsul-flask
tags: latest

View File

@ -1,32 +0,0 @@
FROM python:3.8-alpine as build
RUN apk add gettext git gcc python3-dev musl-dev \
libffi-dev zlib-dev jpeg-dev libjpeg postgresql-dev build-base \
--virtual .build-dependencies
RUN mkdir -p /app/{code,venv}
WORKDIR /app/code
COPY Pipfile Pipfile.lock /app/code/
RUN python3 -m venv /app/venv
RUN pip install pipenv setuptools
ENV PATH="/app/venv/bin:$PATH" VIRTUAL_ENV="/app/venv"
RUN pip install wheel cppy
# Install dependencies into the virtual environment with Pipenv
RUN pipenv install --deploy --verbose
FROM python:3.8-alpine
RUN apk add --no-cache libpq libstdc++ libjpeg
COPY . /app/code/
WORKDIR /app/code
COPY --from=build /app/venv /app/venv
ENV PATH="/app/venv/bin:$PATH" VIRTUAL_ENV="/app/venv"
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--worker-connections", "1000", "app:app"]
VOLUME /app/code
EXPOSE 5000

View File

@ -31,7 +31,6 @@ load_dotenv(find_dotenv())
app = Flask(__name__) app = Flask(__name__)
app.config.from_mapping( app.config.from_mapping(
BASE_URL=os.environ.get("BASE_URL", default="http://localhost:5000"), BASE_URL=os.environ.get("BASE_URL", default="http://localhost:5000"),
SECRET_KEY=os.environ.get("SECRET_KEY", default="dev"), SECRET_KEY=os.environ.get("SECRET_KEY", default="dev"),
HUB_MODE_ENABLED=os.environ.get("HUB_MODE_ENABLED", default="True").lower() in ['true', '1', 't', 'y', 'yes'], HUB_MODE_ENABLED=os.environ.get("HUB_MODE_ENABLED", default="True").lower() in ['true', '1', 't', 'y', 'yes'],
@ -56,7 +55,7 @@ app.config.from_mapping(
MAIL_SERVER=os.environ.get("MAIL_SERVER", default=""), MAIL_SERVER=os.environ.get("MAIL_SERVER", default=""),
MAIL_PORT=os.environ.get("MAIL_PORT", default="465"), MAIL_PORT=os.environ.get("MAIL_PORT", default="465"),
MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="True").lower() in ['true', '1', 't', 'y', 'yes'], MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="False").lower() in ['true', '1', 't', 'y', 'yes'],
MAIL_USE_SSL=os.environ.get("MAIL_USE_SSL", default="True").lower() in ['true', '1', 't', 'y', 'yes'], MAIL_USE_SSL=os.environ.get("MAIL_USE_SSL", default="True").lower() in ['true', '1', 't', 'y', 'yes'],
MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default=""), MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default=""),
MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""), MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""),
@ -72,7 +71,7 @@ app.config.from_mapping(
#STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", default="") #STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", default="")
BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default="").replace("\\n", "\n"), BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default="").replace("\\n", "\n"),
BTCPAY_URL=os.environ.get("BTCPAY_URL", default="https://btcpay.cyberia.club") BTCPAY_URL=os.environ.get("BTCPAY_URL", default="")
) )
app.config['HUB_URL'] = os.environ.get("HUB_URL", default=app.config['BASE_URL']) app.config['HUB_URL'] = os.environ.get("HUB_URL", default=app.config['BASE_URL'])
@ -140,10 +139,13 @@ else:
app.config['HTTP_CLIENT'] = MyHTTPClient(timeout_seconds=int(app.config['INTERNAL_HTTP_TIMEOUT_SECONDS'])) app.config['HTTP_CLIENT'] = MyHTTPClient(timeout_seconds=int(app.config['INTERNAL_HTTP_TIMEOUT_SECONDS']))
try: app.config['BTCPAY_ENABLED'] = False
app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY']) if app.config['BTCPAY_URL'] != "":
except: try:
app.logger.warning("unable to create btcpay client. Capsul will work fine except cryptocurrency payments will not work. The error was: " + my_exec_info_message(sys.exc_info())) app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY'])
app.config['BTCPAY_ENABLED'] = True
except:
app.logger.warning("unable to create btcpay client. Capsul will work fine except cryptocurrency payments will not work. The error was: " + my_exec_info_message(sys.exc_info()))
# only start the scheduler and attempt to migrate the database if we are running the app. # only start the scheduler and attempt to migrate the database if we are running the app.
# otherwise we are running a CLI command. # otherwise we are running a CLI command.
@ -153,6 +155,7 @@ is_running_server = ('flask run' in command_line) or ('gunicorn' in command_line
app.logger.info(f"is_running_server: {is_running_server}") app.logger.info(f"is_running_server: {is_running_server}")
if app.config['HUB_MODE_ENABLED']: if app.config['HUB_MODE_ENABLED']:
if app.config['HUB_MODEL'] == "capsul-flask": if app.config['HUB_MODEL'] == "capsul-flask":
app.config['HUB_MODEL'] = hub_model.CapsulFlaskHub() app.config['HUB_MODEL'] = hub_model.CapsulFlaskHub()
@ -174,9 +177,7 @@ if app.config['HUB_MODE_ENABLED']:
from capsulflask import db from capsulflask import db
db.init_app(app, is_running_server) db.init_app(app, is_running_server)
from capsulflask import ( from capsulflask import auth, landing, console, payment, metrics, cli, hub_api, admin
auth, landing, console, payment, metrics, cli, hub_api, publicapi, admin
)
app.register_blueprint(landing.bp) app.register_blueprint(landing.bp)
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
@ -186,13 +187,13 @@ if app.config['HUB_MODE_ENABLED']:
app.register_blueprint(cli.bp) app.register_blueprint(cli.bp)
app.register_blueprint(hub_api.bp) app.register_blueprint(hub_api.bp)
app.register_blueprint(admin.bp) app.register_blueprint(admin.bp)
app.register_blueprint(publicapi.bp)
app.add_url_rule("/", endpoint="index") app.add_url_rule("/", endpoint="index")
if app.config['SPOKE_MODE_ENABLED']: if app.config['SPOKE_MODE_ENABLED']:
if app.config['SPOKE_MODEL'] == "shell-scripts": if app.config['SPOKE_MODEL'] == "shell-scripts":
app.config['SPOKE_MODEL'] = spoke_model.ShellScriptSpoke() app.config['SPOKE_MODEL'] = spoke_model.ShellScriptSpoke()
else: else:

View File

@ -17,9 +17,9 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.route("/") @bp.route("/")
@admin_account_required @admin_account_required
def index(): def index():
hosts = get_model().list_hosts_with_networks() hosts = get_model().list_hosts_with_networks(None)
vms_by_host_and_network = get_model().all_non_deleted_vms_by_host_and_network() vms_by_host_and_network = get_model().non_deleted_vms_by_host_and_network(None)
network_display_width_px = float(500); network_display_width_px = float(270)
#operations = get_model().list_all_operations() #operations = get_model().list_all_operations()
display_hosts = [] display_hosts = []
@ -35,26 +35,18 @@ def index():
display_host = dict(name=host_id, networks=value['networks']) display_host = dict(name=host_id, networks=value['networks'])
for network in display_host['networks']: for network in display_host['networks']:
ipv4_network = ipaddress.ip_network(network["public_ipv4_cidr_block"], False)
network_start_int = -1
network_end_int = -1
i = 0
for ipv4_address in ipv4_network:
i += 1
if i > 2:
if network_start_int == -1:
network_start_int = int(ipv4_address)
network_end_int = int(ipv4_address) network_start_int = int(ipaddress.ip_address(network["public_ipv4_first_usable_ip"]))
network_end_int = int(ipaddress.ip_address(network["public_ipv4_last_usable_ip"]))
network['allocations'] = [] network['allocations'] = []
network_addresses_width = float((network_end_int-network_start_int)) network_addresses_width = float((network_end_int-network_start_int)+1)
if host_id in vms_by_host_and_network: if host_id in vms_by_host_and_network:
if network['network_name'] in vms_by_host_and_network[host_id]: if network['network_name'] in vms_by_host_and_network[host_id]:
for vm in vms_by_host_and_network[host_id][network['network_name']]: for vm in vms_by_host_and_network[host_id][network['network_name']]:
ip_address_int = int(ipaddress.ip_address(vm['public_ipv4'])) ip_address_int = int(ipaddress.ip_address(vm['public_ipv4']))
if network_start_int < ip_address_int and ip_address_int < network_end_int: if network_start_int <= ip_address_int and ip_address_int <= network_end_int:
allocation = f"{host_id}_{network['network_name']}_{len(network['allocations'])}" allocation = f"{host_id}_{network['network_name']}_{len(network['allocations'])}"
inline_styles.append( inline_styles.append(
f""" f"""

View File

@ -1,4 +1,3 @@
from base64 import b64decode
import functools import functools
import re import re
@ -25,15 +24,6 @@ def account_required(view):
@functools.wraps(view) @functools.wraps(view)
def wrapped_view(**kwargs): def wrapped_view(**kwargs):
api_token = request.headers.get('authorization', None)
if api_token is not None:
email = get_model().authenticate_token(b64decode(api_token).decode('utf-8'))
if email is not None:
session.clear()
session["account"] = email
session["csrf-token"] = generate()
if session.get("account") is None or session.get("csrf-token") is None : if session.get("account") is None or session.get("csrf-token") is None :
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@ -66,7 +56,7 @@ def login():
if not email: if not email:
errors.append("email is required") errors.append("email is required")
elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0: elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0:
errors.append("enter a valid email address") errors.append("enter a valid email address")
if len(errors) == 0: if len(errors) == 0:
result = get_model().login(email) result = get_model().login(email)

View File

@ -1,9 +1,7 @@
from base64 import b64encode
from datetime import datetime, timedelta
import json
import re import re
import sys import sys
import json
from datetime import datetime, timedelta
from flask import Blueprint from flask import Blueprint
from flask import flash from flask import flash
from flask import current_app from flask import current_app
@ -100,6 +98,7 @@ def index():
@bp.route("/<string:id>", methods=("GET", "POST")) @bp.route("/<string:id>", methods=("GET", "POST"))
@account_required @account_required
def detail(id): def detail(id):
duration=request.args.get('duration') duration=request.args.get('duration')
if not duration: if not duration:
duration = "5m" duration = "5m"
@ -189,72 +188,6 @@ def detail(id):
duration=duration duration=duration
) )
def _create(vm_sizes, operating_systems, public_keys_for_account, 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}")
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()
get_model().create_vm(
email=session["account"],
id=id,
size=size,
os=os,
ssh_authorized_keys=list(map(lambda x: x["name"], posted_keys))
)
current_app.config["HUB_MODEL"].create(
email = session["account"],
id=id,
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: x["content"], posted_keys))
)
return id, errors
return None, errors
@bp.route("/create", methods=("GET", "POST")) @bp.route("/create", methods=("GET", "POST"))
@account_required @account_required
@ -264,16 +197,67 @@ def create():
public_keys_for_account = get_model().list_ssh_public_keys_for_account(session["account"]) 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()) account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow())
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024) capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024)
errors = list()
if request.method == "POST": if request.method == "POST":
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
return abort(418, f"u want tea") return abort(418, f"u want tea")
id, errors = _create(
vm_sizes, size = request.form["size"]
operating_systems, os = request.form["os"]
public_keys_for_account, if not size:
request.form) errors.append("Size is required")
elif size not in vm_sizes:
errors.append(f"Invalid size {size}")
if not os:
errors.append("OS is required")
elif os not in operating_systems:
errors.append(f"Invalid os {os}")
posted_keys_count = int(request.form["ssh_authorized_key_count"])
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 request.form:
posted_name = request.form[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: if len(errors) == 0:
id = make_capsul_id()
# we can't create the vm record in the DB yet because its IP address needs to be allocated first.
# so it will be created when the allocation happens inside the hub_api.
current_app.config["HUB_MODEL"].create(
email = session["account"],
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 redirect(f"{url_for('console.index')}?created={id}") return redirect(f"{url_for('console.index')}?created={id}")
affordable_vm_sizes = dict() affordable_vm_sizes = dict()
@ -303,25 +287,23 @@ def create():
vm_sizes=affordable_vm_sizes vm_sizes=affordable_vm_sizes
) )
@bp.route("/keys", methods=("GET", "POST")) @bp.route("/ssh", methods=("GET", "POST"))
@account_required @account_required
def ssh_api_keys(): def ssh_public_keys():
errors = list() errors = list()
token = None
if request.method == "POST": if request.method == "POST":
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
return abort(418, f"u want tea") return abort(418, f"u want tea")
action = request.form["action"] method = request.form["method"]
content = None
if action == 'upload_ssh_key': if method == "POST":
content = None
content = request.form["content"].replace("\r", " ").replace("\n", " ").strip() content = request.form["content"].replace("\r", " ").replace("\n", " ").strip()
name = request.form["name"] name = request.form["name"]
if not name or len(name.strip()) < 1: if not name or len(name.strip()) < 1:
if method == "POST":
parts = re.split(" +", content) parts = re.split(" +", content)
if len(parts) > 2 and len(parts[2].strip()) > 0: if len(parts) > 2 and len(parts[2].strip()) > 0:
name = parts[2].strip() name = parts[2].strip()
@ -329,9 +311,10 @@ def ssh_api_keys():
name = parts[0].strip() name = parts[0].strip()
else: else:
errors.append("Name is required") errors.append("Name is required")
if not re.match(r"^[0-9A-Za-z_@:. -]+$", name): if not re.match(r"^[0-9A-Za-z_@:. -]+$", name):
errors.append(f"Key name '{name}' must match \"^[0-9A-Za-z_@:. -]+$\"") errors.append(f"Key name '{name}' must match \"^[0-9A-Za-z_@:. -]+$\"")
if method == "POST":
if not content or len(content.strip()) < 1: if not content or len(content.strip()) < 1:
errors.append("Content is required") errors.append("Content is required")
else: else:
@ -344,36 +327,24 @@ def ssh_api_keys():
if len(errors) == 0: if len(errors) == 0:
get_model().create_ssh_public_key(session["account"], name, content) get_model().create_ssh_public_key(session["account"], name, content)
elif action == "delete_ssh_key": elif method == "DELETE":
get_model().delete_ssh_public_key(session["account"], name)
elif action == "generate_api_token": if len(errors) == 0:
name = request.form["name"] get_model().delete_ssh_public_key(session["account"], 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: for error in errors:
flash(error) flash(error)
ssh_keys_list=list(map( keys_list=list(map(
lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"), 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"]) get_model().list_ssh_public_keys_for_account(session["account"])
)) ))
api_tokens_list = get_model().list_api_tokens(session["account"])
return render_template( return render_template(
"keys.html", "ssh-public-keys.html",
csrf_token = session["csrf-token"], csrf_token = session["csrf-token"],
api_tokens=api_tokens_list, ssh_public_keys=keys_list,
ssh_public_keys=ssh_keys_list, has_ssh_public_keys=len(keys_list) > 0
generated_api_token=token,
) )
def get_vms(): def get_vms():
@ -397,6 +368,7 @@ def get_vm_months_float(vm, as_of):
return days / average_number_of_days_in_a_month 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:
vm_months = get_vm_months_float(vm, as_of) vm_months = get_vm_months_float(vm, as_of)
@ -409,6 +381,7 @@ 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']) payment_sessions = get_model().list_payment_sessions_for_account(session['account'])
for payment_session in payment_sessions: for payment_session in payment_sessions:
if payment_session['type'] == 'btcpay': if payment_session['type'] == 'btcpay':
@ -450,6 +423,7 @@ def account_balance():
has_vms=len(vms_billed)>0, has_vms=len(vms_billed)>0,
vms_billed=vms_billed, vms_billed=vms_billed,
warning_text=warning_text, warning_text=warning_text,
btcpay_enabled=current_app.config["BTCPAY_ENABLED"],
payments=list(map( payments=list(map(
lambda x: dict( lambda x: dict(
dollars=x["dollars"], dollars=x["dollars"],

View File

@ -33,7 +33,7 @@ def init_app(app, is_running_server):
result = re.search(r"^\d+_(up|down)", filename) result = re.search(r"^\d+_(up|down)", filename)
if not result: if not result:
app.logger.error(f"schemaVersion {filename} must match ^\\d+_(up|down). exiting.") app.logger.error(f"schemaVersion {filename} must match ^\\d+_(up|down). exiting.")
continue exit(1)
key = result.group() key = result.group()
with open(join(schemaMigrationsPath, filename), 'rb') as file: with open(join(schemaMigrationsPath, filename), 'rb') as file:
schemaMigrations[key] = file.read().decode("utf8") schemaMigrations[key] = file.read().decode("utf8")
@ -43,7 +43,7 @@ def init_app(app, is_running_server):
hasSchemaVersionTable = False hasSchemaVersionTable = False
actionWasTaken = False actionWasTaken = False
schemaVersion = 0 schemaVersion = 0
desiredSchemaVersion = 17 desiredSchemaVersion = 18
cursor = connection.cursor() cursor = connection.cursor()
@ -128,3 +128,4 @@ def close_db(e=None):
if db_model is not None: if db_model is not None:
db_model.cursor.close() db_model.cursor.close()
current_app.config['PSYCOPG2_CONNECTION_POOL'].putconn(db_model.connection) current_app.config['PSYCOPG2_CONNECTION_POOL'].putconn(db_model.connection)

View File

@ -1,6 +1,8 @@
import re
# I was never able to get this type hinting to work correctly # I was never able to get this type hinting to work correctly
# from psycopg2.extensions import connection as Psycopg2Connection, cursor as Psycopg2Cursor # from psycopg2.extensions import connection as Psycopg2Connection, cursor as Psycopg2Cursor
import hashlib
from nanoid import generate from nanoid import generate
from flask import current_app from flask import current_app
from typing import List from typing import List
@ -15,6 +17,7 @@ class DBModel:
self.cursor = cursor self.cursor = cursor
# ------ LOGIN --------- # ------ LOGIN ---------
@ -41,16 +44,6 @@ class DBModel:
return (token, ignoreCaseMatches) return (token, ignoreCaseMatches)
def authenticate_token(self, token):
m = hashlib.md5()
m.update(token.encode('utf-8'))
hash_token = m.hexdigest()
self.cursor.execute("SELECT email FROM api_tokens WHERE token = %s", (hash_token, ))
result = self.cursor.fetchall()
if len(result) == 1:
return result[0]
return None
def consume_token(self, token): def consume_token(self, token):
self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s and created > (NOW() - INTERVAL '20 min')", (token, )) self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s and created > (NOW() - INTERVAL '20 min')", (token, ))
row = self.cursor.fetchone() row = self.cursor.fetchone()
@ -65,8 +58,18 @@ class DBModel:
# ------ VM & ACCOUNT MANAGEMENT --------- # ------ VM & ACCOUNT MANAGEMENT ---------
def all_non_deleted_vms_by_host_and_network(self): def non_deleted_vms_by_host_and_network(self, host_id):
self.cursor.execute("SELECT id, host, network_name, public_ipv4, public_ipv6 FROM vms WHERE deleted IS NULL") query = "SELECT id, host, network_name, public_ipv4, public_ipv6 FROM vms WHERE deleted IS NULL"
if host_id is None:
self.cursor.execute(query)
else:
if not re.match(r"^[a-zA-Z0-9_-]+$", host_id):
raise ValueError(f"host_id \"{host_id}\" must match \"^[a-zA-Z0-9_-]+\"")
# I kept getting "TypeError: not all arguments converted during string formatting"
# when I was trying to mix python string templating with psycopg2 safe parameter passing.
# so i just did all of it in python and check the user-provided data for safety myself (no sql injection).
self.cursor.execute(f"{query} AND host = '{host_id}'")
hosts = dict() hosts = dict()
for row in self.cursor.fetchall(): for row in self.cursor.fetchall():
@ -129,32 +132,6 @@ class DBModel:
self.cursor.execute( "DELETE FROM ssh_public_keys where email = %s AND name = %s", (email, name) ) self.cursor.execute( "DELETE FROM ssh_public_keys where email = %s AND name = %s", (email, name) )
self.connection.commit() self.connection.commit()
def list_api_tokens(self, email):
self.cursor.execute(
"SELECT id, token, name, created FROM api_tokens WHERE email = %s",
(email, )
)
return list(map(
lambda x: dict(id=x[0], token=x[1], name=x[2], created=x[3]),
self.cursor.fetchall()
))
def generate_api_token(self, email, name):
token = generate()
m = hashlib.md5()
m.update(token.encode('utf-8'))
hash_token = m.hexdigest()
self.cursor.execute(
"INSERT INTO api_tokens (email, name, token) VALUES (%s, %s, %s)",
(email, name, hash_token)
)
self.connection.commit()
return token
def delete_api_token(self, email, id_):
self.cursor.execute( "DELETE FROM api_tokens where email = %s AND id = %s", (email, id_))
self.connection.commit()
def list_vms_for_account(self, email): def list_vms_for_account(self, email):
self.cursor.execute(""" self.cursor.execute("""
SELECT vms.id, vms.public_ipv4, vms.public_ipv6, vms.size, vms.os, vms.created, vms.deleted, vm_sizes.dollars_per_month SELECT vms.id, vms.public_ipv4, vms.public_ipv6, vms.size, vms.os, vms.created, vms.deleted, vm_sizes.dollars_per_month
@ -181,12 +158,12 @@ class DBModel:
) )
self.connection.commit() self.connection.commit()
def create_vm(self, email, id, size, os, ssh_authorized_keys): def create_vm(self, email, id, size, os, host, network_name, public_ipv4, ssh_authorized_keys):
self.cursor.execute(""" self.cursor.execute("""
INSERT INTO vms (email, id, size, os, network_name, host) INSERT INTO vms (email, id, size, os, host, network_name, public_ipv4)
VALUES (%s, %s, %s, %s, 'virbr1', 'baikal') VALUES (%s, %s, %s, %s, %s, %s, %s)
""", """,
(email, id, size, os) (email, id, size, os, host, network_name, public_ipv4)
) )
for ssh_authorized_key in ssh_authorized_keys: for ssh_authorized_key in ssh_authorized_keys:
@ -353,18 +330,35 @@ class DBModel:
# ------ HOSTS --------- # ------ HOSTS ---------
def list_hosts_with_networks(self): def list_hosts_with_networks(self, host_id: str):
self.cursor.execute(""" query = """
SELECT hosts.id, hosts.last_health_check, host_network.network_name, host_network.public_ipv4_cidr_block FROM hosts SELECT hosts.id, hosts.last_health_check, host_network.network_name,
host_network.public_ipv4_cidr_block, host_network.public_ipv4_first_usable_ip, host_network.public_ipv4_last_usable_ip
FROM hosts
JOIN host_network ON host_network.host = hosts.id JOIN host_network ON host_network.host = hosts.id
""") """
if host_id is None:
self.cursor.execute(query)
else:
if not re.match(r"^[a-zA-Z0-9_-]+$", host_id):
raise ValueError(f"host_id \"{host_id}\" must match \"^[a-zA-Z0-9_-]+\"")
# I kept getting "TypeError: not all arguments converted during string formatting"
# when I was trying to mix python query string templating with psycopg2 safe parameter passing.
# so i just did all of it in python and check the user-provided data for safety myself (no sql injection).
self.cursor.execute(f"{query} WHERE hosts.id = '{host_id}'")
hosts = dict() hosts = dict()
for row in self.cursor.fetchall(): for row in self.cursor.fetchall():
if row[0] not in hosts: if row[0] not in hosts:
hosts[row[0]] = dict(last_health_check=row[1], networks=[]) hosts[row[0]] = dict(last_health_check=row[1], networks=[])
hosts[row[0]]["networks"].append(dict(network_name=row[2], public_ipv4_cidr_block=row[3])) hosts[row[0]]["networks"].append(dict(
network_name=row[2],
public_ipv4_cidr_block=row[3],
public_ipv4_first_usable_ip=row[4],
public_ipv4_last_usable_ip=row[5]
))
return hosts return hosts
@ -414,6 +408,13 @@ class DBModel:
self.connection.commit() self.connection.commit()
return operation_id return operation_id
def update_operation(self, operation_id: int, payload: str):
self.cursor.execute(
"UPDATE operations SET payload = %s WHERE id = %s",
(payload, operation_id)
)
self.connection.commit()
def update_host_operation(self, host_id: str, operation_id: int, assignment_status: str, result: str): def update_host_operation(self, host_id: str, operation_id: int, assignment_status: str, result: str):
if assignment_status and not result: if assignment_status and not result:
self.cursor.execute( self.cursor.execute(
@ -440,9 +441,20 @@ class DBModel:
else: else:
return None return None
def host_operation_exists(self, operation_id: int, host_id: str) -> bool: def get_payload_json_from_host_operation(self, operation_id: int, host_id: str) -> str:
self.cursor.execute("SELECT operation FROM host_operation WHERE host = %s AND operation = %s",(host_id, operation_id)) self.cursor.execute(
return len(self.cursor.fetchall()) != 0 """
SELECT operations.payload FROM operations
JOIN host_operation ON host_operation.operation = operations.id
WHERE host_operation.host = %s AND host_operation.operation = %s
""",
(host_id, operation_id)
)
row = self.cursor.fetchone()
if row:
return row[0]
else:
return None
def claim_operation(self, operation_id: int, host_id: str) -> bool: def claim_operation(self, operation_id: int, host_id: str) -> bool:
# have to make a new cursor to set isolation level # have to make a new cursor to set isolation level
@ -467,3 +479,8 @@ class DBModel:
#cursor.close() #cursor.close()
return to_return return to_return

View File

@ -1,7 +1,9 @@
import json
import ipaddress
from flask import Blueprint from flask import Blueprint
from flask import current_app from flask import current_app
from flask import request from flask import request, make_response
from werkzeug.exceptions import abort from werkzeug.exceptions import abort
from capsulflask.db import get_model from capsulflask.db import get_model
@ -46,15 +48,134 @@ def heartbeat(host_id):
@bp.route("/claim-operation/<int:operation_id>/<string:host_id>", methods=("POST",)) @bp.route("/claim-operation/<int:operation_id>/<string:host_id>", methods=("POST",))
def claim_operation(operation_id: int, host_id: str): def claim_operation(operation_id: int, host_id: str):
if authorized_for_host(host_id): if authorized_for_host(host_id):
exists = get_model().host_operation_exists(operation_id, host_id) payload_json = get_model().get_payload_json_from_host_operation(operation_id, host_id)
if not exists: if payload_json is None:
return abort(404, "host operation not found") error_message = f"{host_id} can't claim operation {operation_id} because host_operation row not found"
current_app.logger.error(error_message)
return abort(404, error_message)
# right now if there is a can_claim_handler there needs to be a corresponding on_claimed_handler.
# this is sole-ly due to laziness in error handling.
can_claim_handlers = {
"create": can_claim_create,
}
on_claimed_handlers = {
"create": on_create_claimed,
}
error_message = ""
payload = None
payload_is_dict = False
payload_has_type = False
payload_has_valid_type = False
try:
payload = json.loads(payload_json)
payload_is_dict = isinstance(payload, dict)
payload_has_type = payload_is_dict and 'type' in payload
payload_has_valid_type = payload_has_type and payload['type'] in can_claim_handlers
if not payload_is_dict:
error_message = "invalid json: expected an object"
elif not payload_has_type:
error_message = "invalid json: 'type' field is required"
elif not payload_has_valid_type:
error_message = f"invalid json: expected type \"{payload['type']}\" to be one of [{', '.join(can_claim_handlers.keys())}]"
except:
error_message = "could not parse payload as json"
if error_message != "":
error_message = f"{host_id} can't claim operation {operation_id} because {error_message}"
current_app.logger.error(error_message)
return abort(400, error_message)
# we will only return this payload as json if claiming succeeds, so might as well do this now...
payload['assignment_status'] = 'assigned'
# invoke the appropriate can_claim_handler for this operation type
result_tuple = can_claim_handlers[payload['type']](payload, host_id)
modified_payload = result_tuple[0]
error_message = result_tuple[1]
if error_message != "":
error_message = f"{host_id} can't claim operation {operation_id} because {error_message}"
current_app.logger.error(error_message)
return abort(400, error_message)
claimed = get_model().claim_operation(operation_id, host_id) claimed = get_model().claim_operation(operation_id, host_id)
if claimed: if claimed:
return "ok" modified_payload_json = json.dumps(modified_payload)
get_model().update_operation(operation_id, modified_payload_json)
on_claimed_handlers[payload['type']](modified_payload, host_id)
response = make_response(modified_payload_json)
response.headers.set("Content-Type", "application/json")
return response
else: else:
return abort(409, "operation was already assigned to another host") return abort(409, f"operation was already assigned to another host")
else: else:
current_app.logger.warning(f"/hub/claim-operation/{operation_id}/{host_id} returned 401: invalid token") current_app.logger.warning(f"/hub/claim-operation/{operation_id}/{host_id} returned 401: invalid token")
return abort(401, "invalid host id or token") return abort(401, "invalid host id or token")
def can_claim_create(payload, host_id) -> (str, str):
hosts = get_model().list_hosts_with_networks(host_id)
if host_id not in hosts:
return "", f"the host \"{host_id}\" does not appear to have any networks."
networks = hosts[host_id]['networks']
vms_by_host_and_network = get_model().non_deleted_vms_by_host_and_network(host_id)
vms_by_network = dict()
if host_id in vms_by_host_and_network:
vms_by_network = vms_by_host_and_network[host_id]
allocated_ipv4_address = None
allocated_network_name = None
for network in networks:
vms = []
if network["network_name"] in vms_by_network:
vms = vms_by_network[network["network_name"]]
claimed_ipv4s = dict()
for vm in vms:
claimed_ipv4s[vm['public_ipv4']] = True
ipv4_network = ipaddress.ip_network(network["public_ipv4_cidr_block"], False)
ipv4_first_usable_ip = network["public_ipv4_first_usable_ip"]
ipv4_last_usable_ip = network["public_ipv4_last_usable_ip"]
for ipv4_address in ipv4_network:
within_usable_range = ipv4_first_usable_ip <= str(ipv4_address) and str(ipv4_address) <= ipv4_last_usable_ip
if within_usable_range and str(ipv4_address) not in claimed_ipv4s:
allocated_ipv4_address = str(ipv4_address)
break
if allocated_ipv4_address is not None:
allocated_network_name = network["network_name"]
break
if allocated_network_name is None or allocated_ipv4_address is None:
return "", f"host \"{host_id}\" does not have any avaliable IP addresses on any of its networks."
# payload["network_name"] = allocated_network_name
# hard-code the network name for now until we can fix the phantom dhcp lease issues.
payload["network_name"] = 'public3'
payload["public_ipv4"] = allocated_ipv4_address
return payload, ""
def on_create_claimed(payload, host_id):
get_model().create_vm(
email=payload['email'],
id=payload['id'],
size=payload['size'],
os=payload['os'],
host=host_id,
network_name=payload['network_name'],
public_ipv4=payload['public_ipv4'],
ssh_authorized_keys=list(map(lambda x: x["name"], payload['ssh_authorized_keys'])),
)

View File

@ -17,6 +17,10 @@ from capsulflask.http_client import HTTPResult
from capsulflask.shared import VirtualizationInterface, VirtualMachine, OnlineHost, validate_capsul_id, my_exec_info_message from capsulflask.shared import VirtualizationInterface, VirtualMachine, OnlineHost, validate_capsul_id, my_exec_info_message
class MockHub(VirtualizationInterface): class MockHub(VirtualizationInterface):
def __init__(self):
self.default_network = "public1"
self.default_ipv4 = "1.1.1.1"
def capacity_avaliable(self, additional_ram_bytes): def capacity_avaliable(self, additional_ram_bytes):
return True return True
@ -29,17 +33,27 @@ class MockHub(VirtualizationInterface):
{"key_type":"RSA", "content":"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvotgzgEP65JUQ8S8OoNKy1uEEPEAcFetSp7QpONe6hj4wPgyFNgVtdoWdNcU19dX3hpdse0G8OlaMUTnNVuRlbIZXuifXQ2jTtCFUA2mmJ5bF+XjGm3TXKMNGh9PN+wEPUeWd14vZL+QPUMev5LmA8cawPiU5+vVMLid93HRBj118aCJFQxLgrdP48VPfKHFRfCR6TIjg1ii3dH4acdJAvlmJ3GFB6ICT42EmBqskz2MPe0rIFxH8YohCBbAbrbWYcptHt4e48h4UdpZdYOhEdv89GrT8BF2C5cbQ5i9qVpI57bXKrj8hPZU5of48UHLSpXG8mbH0YDiOQOfKX/Mt", "sha256":"ghee6KzRnBJhND2kEUZSaouk7CD6o6z2aAc8GPkV+GQ"}, {"key_type":"RSA", "content":"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvotgzgEP65JUQ8S8OoNKy1uEEPEAcFetSp7QpONe6hj4wPgyFNgVtdoWdNcU19dX3hpdse0G8OlaMUTnNVuRlbIZXuifXQ2jTtCFUA2mmJ5bF+XjGm3TXKMNGh9PN+wEPUeWd14vZL+QPUMev5LmA8cawPiU5+vVMLid93HRBj118aCJFQxLgrdP48VPfKHFRfCR6TIjg1ii3dH4acdJAvlmJ3GFB6ICT42EmBqskz2MPe0rIFxH8YohCBbAbrbWYcptHt4e48h4UdpZdYOhEdv89GrT8BF2C5cbQ5i9qVpI57bXKrj8hPZU5of48UHLSpXG8mbH0YDiOQOfKX/Mt", "sha256":"ghee6KzRnBJhND2kEUZSaouk7CD6o6z2aAc8GPkV+GQ"},
{"key_type":"ECDSA", "content":"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLLgOoATz9R4aS2kk7vWoxX+lshK63t9+5BIHdzZeFE1o+shlcf0Wji8cN/L1+m3bi0uSETZDOAWMP3rHLJj9Hk=", "sha256":"aCYG1aD8cv/TjzJL0bi9jdabMGksdkfa7R8dCGm1yYs"} {"key_type":"ECDSA", "content":"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLLgOoATz9R4aS2kk7vWoxX+lshK63t9+5BIHdzZeFE1o+shlcf0Wji8cN/L1+m3bi0uSETZDOAWMP3rHLJj9Hk=", "sha256":"aCYG1aD8cv/TjzJL0bi9jdabMGksdkfa7R8dCGm1yYs"}
]""") ]""")
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4="1.1.1.1", ssh_host_keys=ssh_host_keys) return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=self.default_ipv4, ssh_host_keys=ssh_host_keys)
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4="1.1.1.1") return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=self.default_ipv4)
def list_ids(self) -> list: def list_ids(self) -> list:
return get_model().all_non_deleted_vm_ids() return get_model().all_non_deleted_vm_ids()
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list): def create(self, email: str, id: str, os: str, size: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list):
validate_capsul_id(id) validate_capsul_id(id)
current_app.logger.info(f"mock create: {id} for {email}") current_app.logger.info(f"mock create: {id} for {email}")
sleep(1) sleep(1)
get_model().create_vm(
email=email,
id=id,
size=size,
os=os,
host=current_app.config["SPOKE_HOST_ID"],
network_name=self.default_network,
public_ipv4=self.default_ipv4,
ssh_authorized_keys=list(map(lambda x: x["name"], ssh_authorized_keys)),
)
def destroy(self, email: str, id: str): def destroy(self, email: str, id: str):
current_app.logger.info(f"mock destroy: {id} for {email}") current_app.logger.info(f"mock destroy: {id} for {email}")
@ -49,7 +63,6 @@ class MockHub(VirtualizationInterface):
class CapsulFlaskHub(VirtualizationInterface): class CapsulFlaskHub(VirtualizationInterface):
def synchronous_operation(self, hosts: List[OnlineHost], email: str, payload: str) -> List[HTTPResult]: def synchronous_operation(self, hosts: List[OnlineHost], email: str, payload: str) -> List[HTTPResult]:
return self.generic_operation(hosts, email, payload, True)[1] return self.generic_operation(hosts, email, payload, True)[1]
@ -180,7 +193,7 @@ class CapsulFlaskHub(VirtualizationInterface):
return to_return return to_return
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list): def create(self, email: str, id: str, os: str, size: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list):
validate_capsul_id(id) validate_capsul_id(id)
online_hosts = get_model().get_online_hosts() online_hosts = get_model().get_online_hosts()
#current_app.logger.debug(f"hub_model.create(): ${len(online_hosts)} hosts") #current_app.logger.debug(f"hub_model.create(): ${len(online_hosts)} hosts")
@ -188,6 +201,8 @@ class CapsulFlaskHub(VirtualizationInterface):
type="create", type="create",
email=email, email=email,
id=id, id=id,
os=os,
size=size,
template_image_file_name=template_image_file_name, template_image_file_name=template_image_file_name,
vcpus=vcpus, vcpus=vcpus,
memory_mb=memory_mb, memory_mb=memory_mb,
@ -260,4 +275,3 @@ class CapsulFlaskHub(VirtualizationInterface):
if not result_status == "success": if not result_status == "success":
raise ValueError(f"""failed to {command} vm "{id}" on host "{host.id}" for {email}: {result_json_string}""") raise ValueError(f"""failed to {command} vm "{id}" on host "{host.id}" for {email}: {result_json_string}""")

View File

@ -12,9 +12,11 @@ def index():
@bp.route("/pricing") @bp.route("/pricing")
def pricing(): def pricing():
vm_sizes = get_model().vm_sizes_dict()
operating_systems = get_model().operating_systems_dict() operating_systems = get_model().operating_systems_dict()
return render_template( return render_template(
"pricing.html", "pricing.html",
vm_sizes=vm_sizes,
operating_systems=operating_systems operating_systems=operating_systems
) )

View File

@ -48,6 +48,10 @@ def validate_dollars():
def btcpay_payment(): def btcpay_payment():
errors = list() errors = list()
if not current_app.config['BTCPAY_ENABLED']:
flash("BTCPay is not enabled on this server")
return redirect(url_for("console.account_balance"))
if request.method == "POST": if request.method == "POST":
result = validate_dollars() result = validate_dollars()
errors = result[0] errors = result[0]

View File

@ -1,40 +0,0 @@
import datetime
from flask import Blueprint
from flask import current_app
from flask import jsonify
from flask import request
from flask import session
from nanoid import generate
from capsulflask.auth import account_required
from capsulflask.db import get_model
bp = Blueprint("publicapi", __name__, url_prefix="/api")
@bp.route("/capsul/create", methods=["POST"])
@account_required
def capsul_create():
email = session["account"]
from .console import _create,get_account_balance, get_payments, get_vms
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.datetime.utcnow())
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024)
request.json['ssh_authorized_key_count'] = 1
id, errors = _create(
vm_sizes,
operating_systems,
public_keys_for_account,
request.json)
if id is not None:
return jsonify(
id=id,
)
return jsonify(errors=errors)

View File

@ -3,12 +3,12 @@ CREATE TABLE host_network (
public_ipv4_cidr_block TEXT NOT NULL, public_ipv4_cidr_block TEXT NOT NULL,
network_name TEXT NOT NULL, network_name TEXT NOT NULL,
host TEXT NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT, host TEXT NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT,
PRIMARY KEY (host, network_name) CONSTRAINT host_network_pkey PRIMARY KEY (host, network_name)
); );
INSERT INTO host_network (host, network_name, public_ipv4_cidr_block) VALUES ('baikal', 'virbr1', '69.61.2.162/27'), INSERT INTO host_network (host, network_name, public_ipv4_cidr_block) VALUES ('baikal', 'virbr1', '69.61.2.162/27'),
('baikal', 'virbr2', '69.61.2.194/26'); ('baikal', 'virbr2', '69.61.2.194/26'),
('baikal', 'virbr3', '69.61.38.193/26');
ALTER TABLE vms RENAME COLUMN last_seen_ipv4 TO public_ipv4; ALTER TABLE vms RENAME COLUMN last_seen_ipv4 TO public_ipv4;
ALTER TABLE vms RENAME COLUMN last_seen_ipv6 TO public_ipv6; ALTER TABLE vms RENAME COLUMN last_seen_ipv6 TO public_ipv6;
ALTER TABLE vms ADD COLUMN network_name TEXT; ALTER TABLE vms ADD COLUMN network_name TEXT;
@ -18,7 +18,8 @@ UPDATE vms SET network_name = 'virbr2' WHERE public_ipv4 >= '69.61.2.192';
ALTER TABLE vms ALTER COLUMN network_name SET NOT NULL; ALTER TABLE vms ALTER COLUMN network_name SET NOT NULL;
ALTER TABLE vms ADD FOREIGN KEY (host, network_name) REFERENCES host_network(host, network_name) ON DELETE RESTRICT; ALTER TABLE vms ADD CONSTRAINT vms_host_network_name_fkey FOREIGN KEY (host, network_name)
REFERENCES host_network(host, network_name) ON DELETE RESTRICT;
UPDATE schemaversion SET version = 16; UPDATE schemaversion SET version = 16;

View File

@ -1,2 +0,0 @@
DROP TABLE api_keys;
UPDATE schemaversion SET version = 16;

View File

@ -0,0 +1,18 @@
ALTER TABLE vms DROP CONSTRAINT vms_host_network_name_fkey;
UPDATE host_network SET network_name = 'virbr1' WHERE virtual_bridge_name = "virbr1";
UPDATE host_network SET network_name = 'virbr2' WHERE virtual_bridge_name = "virbr1";
UPDATE host_network SET network_name = 'virbr3' WHERE virtual_bridge_name = "virbr2";
UPDATE vms SET network_name = 'virbr1' WHERE network_name = "public1";
UPDATE vms SET network_name = 'virbr2' WHERE network_name = "public2";
UPDATE vms SET network_name = 'virbr3' WHERE network_name = "public3";
ALTER TABLE host_network DROP COLUMN virtual_bridge_name;
ALTER TABLE vms ADD CONSTRAINT vms_host_network_name_fkey FOREIGN KEY (host, network_name)
REFERENCES host_network(host, network_name) ON DELETE RESTRICT;
UPDATE schemaversion SET version = 16;

View File

@ -1,9 +0,0 @@
CREATE TABLE api_tokens (
id SERIAL PRIMARY KEY,
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
name TEXT NOT NULL,
created TIMESTAMP NOT NULL DEFAULT NOW(),
token TEXT NOT NULL
);
UPDATE schemaversion SET version = 17;

View File

@ -0,0 +1,26 @@
ALTER TABLE vms DROP CONSTRAINT vms_host_network_name_fkey;
ALTER TABLE host_network RENAME COLUMN network_name TO virtual_bridge_name;
ALTER TABLE host_network ADD COLUMN network_name TEXT;
UPDATE host_network SET network_name = 'public1' WHERE virtual_bridge_name = 'virbr1';
UPDATE host_network SET network_name = 'public2' WHERE virtual_bridge_name = 'virbr2';
UPDATE host_network SET network_name = 'public3' WHERE virtual_bridge_name = 'virbr3';
UPDATE vms SET network_name = 'public1' WHERE network_name = 'virbr1';
UPDATE vms SET network_name = 'public2' WHERE network_name = 'virbr2';
UPDATE vms SET network_name = 'public3' WHERE network_name = 'virbr3';
ALTER TABLE host_network ALTER COLUMN network_name SET NOT NULL;
ALTER TABLE host_network DROP CONSTRAINT host_network_pkey;
ALTER TABLE host_network ADD CONSTRAINT host_network_pkey PRIMARY KEY (host, network_name);
ALTER TABLE vms ADD CONSTRAINT vms_host_network_name_fkey FOREIGN KEY (host, network_name)
REFERENCES host_network(host, network_name) ON DELETE RESTRICT;
UPDATE schemaversion SET version = 17;

View File

@ -0,0 +1,6 @@
ALTER TABLE host_network DROP COLUMN public_ipv4_first_usable_ip;
ALTER TABLE host_network DROP COLUMN public_ipv4_last_usable_ip;
UPDATE schemaversion SET version = 17;

View File

@ -0,0 +1,25 @@
ALTER TABLE host_network ADD COLUMN public_ipv4_first_usable_ip TEXT;
ALTER TABLE host_network ADD COLUMN public_ipv4_last_usable_ip TEXT;
-- public1, 69.61.2.162/27
UPDATE host_network
SET public_ipv4_first_usable_ip = '69.61.2.163', public_ipv4_last_usable_ip = '69.61.2.190'
WHERE network_name = 'public1';
-- public2, 69.61.2.194/26
UPDATE host_network
SET public_ipv4_first_usable_ip = '69.61.2.195', public_ipv4_last_usable_ip = '69.61.2.254'
WHERE network_name = 'public2';
-- public3, 69.61.38.193/26
UPDATE host_network
SET public_ipv4_first_usable_ip = '69.61.38.194', public_ipv4_last_usable_ip = '69.61.38.254'
WHERE network_name = 'public3';
ALTER TABLE host_network ALTER COLUMN public_ipv4_first_usable_ip SET NOT NULL;
ALTER TABLE host_network ALTER COLUMN public_ipv4_last_usable_ip SET NOT NULL;
UPDATE schemaversion SET version = 18;

View File

@ -19,9 +19,9 @@ if [ "$ram_bytes_remainder" -le $((20 * 1024 * 1024 * 1024)) ]; then
exit 1 exit 1
fi fi
ipv4_limit=28 ipv4_limit=61
used_ips=$(grep ip-add "/var/lib/libvirt/dnsmasq/virbr1.status" | cut -d '"' -f 4) used_ips=$(grep ip-add "/var/lib/libvirt/dnsmasq/virbr3.status" | cut -d '"' -f 4)
reserved_ips=$(cat "/var/lib/libvirt/dnsmasq/public1.hostsfile" | cut -d ',' -f 2) reserved_ips=$(cat "/var/lib/libvirt/dnsmasq/public3.hostsfile" | cut -d ',' -f 2)
total_addresses_used=$(printf "$used_ips\n$reserved_ips" | sort | uniq | wc -l) total_addresses_used=$(printf "$used_ips\n$reserved_ips" | sort | uniq | wc -l)
ipv4_count=$(printf "$total_addresses_used") ipv4_count=$(printf "$total_addresses_used")

View File

@ -9,6 +9,8 @@ template_file="/tank/img/$2"
vcpus="$3" vcpus="$3"
memory="$4" memory="$4"
pubkeys="$5" pubkeys="$5"
network_name="$6"
public_ipv4="$7"
root_volume_size="25G" root_volume_size="25G"
if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then
@ -38,6 +40,16 @@ echo "$pubkeys" | while IFS= read -r line; do
fi fi
done done
if echo "$network_name" | grep -vqE "^[a-zA-Z0-9_-]+"; then
echo "network_name \"$network_name\" must match ^[a-zA-Z0-9_-]+"
exit 1
fi
if echo "$public_ipv4" | grep -vqE "^[0-9.]+$"; then
echo "public_ipv4 \"$public_ipv4\" must match ^[0-9.]+$"
exit 1
fi
disk="/tank/vm/$vmname.qcow2" disk="/tank/vm/$vmname.qcow2"
cdrom="/tank/vm/$vmname.iso" cdrom="/tank/vm/$vmname.iso"
xml="/tank/vm/$vmname.xml" xml="/tank/vm/$vmname.xml"
@ -66,7 +78,7 @@ virt-install \
--os-variant generic \ --os-variant generic \
--virt-type kvm \ --virt-type kvm \
--graphics vnc,listen=127.0.0.1 \ --graphics vnc,listen=127.0.0.1 \
--network network=public1,filterref=clean-traffic,model=virtio \ --network network=$network_name,filterref=clean-traffic,model=virtio \
--import \ --import \
--print-xml > "$xml" --print-xml > "$xml"

View File

@ -104,7 +104,7 @@ def handle_create(operation_id, request_body):
current_app.logger.error(f"/hosts/operation returned 400: operation_id is required for create ") current_app.logger.error(f"/hosts/operation returned 400: operation_id is required for create ")
return abort(400, f"bad request; operation_id is required. try POST /spoke/operation/<id>") return abort(400, f"bad request; operation_id is required. try POST /spoke/operation/<id>")
parameters = ["email", "id", "template_image_file_name", "vcpus", "memory_mb", "ssh_authorized_keys"] parameters = ["email", "id", "os", "size", "template_image_file_name", "vcpus", "memory_mb", "ssh_authorized_keys"]
error_message = "" error_message = ""
for parameter in parameters: for parameter in parameters:
if parameter not in request_body: if parameter not in request_body:
@ -115,14 +115,28 @@ def handle_create(operation_id, request_body):
return abort(400, f"bad request; {error_message}") return abort(400, f"bad request; {error_message}")
# only one host should create the vm, so we first race to assign this create operation to ourselves. # only one host should create the vm, so we first race to assign this create operation to ourselves.
# only one host will win this race # only one host will win this race.
authorization_header = f"Bearer {current_app.config['SPOKE_HOST_TOKEN']}" authorization_header = f"Bearer {current_app.config['SPOKE_HOST_TOKEN']}"
url = f"{current_app.config['HUB_URL']}/hub/claim-operation/{operation_id}/{current_app.config['SPOKE_HOST_ID']}" url = f"{current_app.config['HUB_URL']}/hub/claim-operation/{operation_id}/{current_app.config['SPOKE_HOST_ID']}"
result = current_app.config['HTTP_CLIENT'].do_http_sync(url, body=None, authorization_header=authorization_header) result = current_app.config['HTTP_CLIENT'].do_http_sync(url, body=None, authorization_header=authorization_header)
assignment_status = "" assignment_status = ""
if result.status_code == 200: if result.status_code == 200:
assignment_status = "assigned" try:
assignment_info = json.loads(result.body)
if not isinstance(assignment_info, dict):
return abort(503, f"hub at '{url}' returned 200, but did not return assignment_info json object")
if 'network_name' not in assignment_info:
return abort(503, f"hub at '{url}' returned 200, but the returned assignment_info object did not include network_name")
if 'public_ipv4' not in assignment_info:
return abort(503, f"hub at '{url}' returned 200, but the returned assignment_info object did not include public_ipv4")
assignment_status = "assigned"
request_body['network_name'] = assignment_info['network_name']
request_body['public_ipv4'] = assignment_info['public_ipv4']
except:
return abort(503, f"hub at '{url}' returned 200, but did not return valid json")
elif result.status_code == 409: elif result.status_code == 409:
assignment_status = "assigned_to_other_host" assignment_status = "assigned_to_other_host"
else: else:
@ -137,7 +151,9 @@ def handle_create(operation_id, request_body):
template_image_file_name=request_body['template_image_file_name'], template_image_file_name=request_body['template_image_file_name'],
vcpus=request_body['vcpus'], vcpus=request_body['vcpus'],
memory_mb=request_body['memory_mb'], memory_mb=request_body['memory_mb'],
ssh_authorized_keys=request_body['ssh_authorized_keys'], ssh_authorized_keys=list(map(lambda x: x['content'], request_body['ssh_authorized_keys'])),
network_name=request_body['network_name'],
public_ipv4=request_body['public_ipv4'],
) )
except: except:
error_message = my_exec_info_message(sys.exc_info()) error_message = my_exec_info_message(sys.exc_info())
@ -147,7 +163,11 @@ def handle_create(operation_id, request_body):
params= f"{params} vcpus='{request_body['vcpus'] if 'vcpus' in request_body else 'KeyError'}', " params= f"{params} vcpus='{request_body['vcpus'] if 'vcpus' in request_body else 'KeyError'}', "
params= f"{params} memory_mb='{request_body['memory_mb'] if 'memory_mb' in request_body else 'KeyError'}', " params= f"{params} memory_mb='{request_body['memory_mb'] if 'memory_mb' in request_body else 'KeyError'}', "
params= f"{params} ssh_authorized_keys='{request_body['ssh_authorized_keys'] if 'ssh_authorized_keys' in request_body else 'KeyError'}', " params= f"{params} ssh_authorized_keys='{request_body['ssh_authorized_keys'] if 'ssh_authorized_keys' in request_body else 'KeyError'}', "
params= f"{params} network_name='{request_body['network_name'] if 'network_name' in request_body else 'KeyError'}', "
params= f"{params} public_ipv4='{request_body['public_ipv4'] if 'public_ipv4' in request_body else 'KeyError'}', "
current_app.logger.error(f"spoke_model.create({params}) failed: {error_message}") current_app.logger.error(f"spoke_model.create({params}) failed: {error_message}")
return jsonify(dict(assignment_status=assignment_status, error_message=error_message)) return jsonify(dict(assignment_status=assignment_status, error_message=error_message))
return jsonify(dict(assignment_status=assignment_status)) return jsonify(dict(assignment_status=assignment_status))

View File

@ -14,28 +14,37 @@ from capsulflask.shared import VirtualizationInterface, VirtualMachine, validate
class MockSpoke(VirtualizationInterface): class MockSpoke(VirtualizationInterface):
def __init__(self):
self.capsuls = dict()
def capacity_avaliable(self, additional_ram_bytes): def capacity_avaliable(self, additional_ram_bytes):
return True return True
def get(self, id, get_ssh_host_keys): def get(self, id, get_ssh_host_keys):
validate_capsul_id(id) validate_capsul_id(id)
ipv4 = "1.1.1.1"
if id in self.capsuls:
ipv4 = self.capsuls[id]['public_ipv4']
if get_ssh_host_keys: if get_ssh_host_keys:
ssh_host_keys = json.loads("""[ ssh_host_keys = json.loads("""[
{"key_type":"ED25519", "content":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8cna0zeKSKl/r8whdn/KmDWhdzuWRVV0GaKIM+eshh", "sha256":"V4X2apAF6btGAfS45gmpldknoDX0ipJ5c6DLfZR2ttQ"}, {"key_type":"ED25519", "content":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8cna0zeKSKl/r8whdn/KmDWhdzuWRVV0GaKIM+eshh", "sha256":"V4X2apAF6btGAfS45gmpldknoDX0ipJ5c6DLfZR2ttQ"},
{"key_type":"RSA", "content":"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvotgzgEP65JUQ8S8OoNKy1uEEPEAcFetSp7QpONe6hj4wPgyFNgVtdoWdNcU19dX3hpdse0G8OlaMUTnNVuRlbIZXuifXQ2jTtCFUA2mmJ5bF+XjGm3TXKMNGh9PN+wEPUeWd14vZL+QPUMev5LmA8cawPiU5+vVMLid93HRBj118aCJFQxLgrdP48VPfKHFRfCR6TIjg1ii3dH4acdJAvlmJ3GFB6ICT42EmBqskz2MPe0rIFxH8YohCBbAbrbWYcptHt4e48h4UdpZdYOhEdv89GrT8BF2C5cbQ5i9qVpI57bXKrj8hPZU5of48UHLSpXG8mbH0YDiOQOfKX/Mt", "sha256":"ghee6KzRnBJhND2kEUZSaouk7CD6o6z2aAc8GPkV+GQ"}, {"key_type":"RSA", "content":"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvotgzgEP65JUQ8S8OoNKy1uEEPEAcFetSp7QpONe6hj4wPgyFNgVtdoWdNcU19dX3hpdse0G8OlaMUTnNVuRlbIZXuifXQ2jTtCFUA2mmJ5bF+XjGm3TXKMNGh9PN+wEPUeWd14vZL+QPUMev5LmA8cawPiU5+vVMLid93HRBj118aCJFQxLgrdP48VPfKHFRfCR6TIjg1ii3dH4acdJAvlmJ3GFB6ICT42EmBqskz2MPe0rIFxH8YohCBbAbrbWYcptHt4e48h4UdpZdYOhEdv89GrT8BF2C5cbQ5i9qVpI57bXKrj8hPZU5of48UHLSpXG8mbH0YDiOQOfKX/Mt", "sha256":"ghee6KzRnBJhND2kEUZSaouk7CD6o6z2aAc8GPkV+GQ"},
{"key_type":"ECDSA", "content":"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLLgOoATz9R4aS2kk7vWoxX+lshK63t9+5BIHdzZeFE1o+shlcf0Wji8cN/L1+m3bi0uSETZDOAWMP3rHLJj9Hk=", "sha256":"aCYG1aD8cv/TjzJL0bi9jdabMGksdkfa7R8dCGm1yYs"} {"key_type":"ECDSA", "content":"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLLgOoATz9R4aS2kk7vWoxX+lshK63t9+5BIHdzZeFE1o+shlcf0Wji8cN/L1+m3bi0uSETZDOAWMP3rHLJj9Hk=", "sha256":"aCYG1aD8cv/TjzJL0bi9jdabMGksdkfa7R8dCGm1yYs"}
]""") ]""")
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4="1.1.1.1", state="running", ssh_host_keys=ssh_host_keys) return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=ipv4, state="running", ssh_host_keys=ssh_host_keys)
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4="1.1.1.1", state="running") return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=ipv4, state="running")
def list_ids(self) -> list: def list_ids(self) -> list:
return get_model().all_non_deleted_vm_ids() return get_model().all_non_deleted_vm_ids()
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list): def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list, network_name: str, public_ipv4: str):
validate_capsul_id(id) validate_capsul_id(id)
current_app.logger.info(f"mock create: {id} for {email}") current_app.logger.info(f"mock create: {id} for {email}")
self.capsuls[id] = dict(email=email, id=id, network_name=network_name, public_ipv4=public_ipv4)
sleep(1) sleep(1)
def destroy(self, email: str, id: str): def destroy(self, email: str, id: str):
@ -129,7 +138,7 @@ class ShellScriptSpoke(VirtualizationInterface):
self.validate_completed_process(completedProcess) self.validate_completed_process(completedProcess)
return list(map(lambda x: x.decode("utf-8"), completedProcess.stdout.splitlines() )) return list(map(lambda x: x.decode("utf-8"), completedProcess.stdout.splitlines() ))
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list): def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list, network_name: str, public_ipv4: str):
validate_capsul_id(id) validate_capsul_id(id)
if not re.match(r"^[a-zA-Z0-9/_.-]+$", template_image_file_name): if not re.match(r"^[a-zA-Z0-9/_.-]+$", template_image_file_name):
@ -139,12 +148,18 @@ class ShellScriptSpoke(VirtualizationInterface):
if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$", ssh_authorized_key): if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$", ssh_authorized_key):
raise ValueError(f"ssh_authorized_key \"{ssh_authorized_key}\" must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$\"") raise ValueError(f"ssh_authorized_key \"{ssh_authorized_key}\" must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$\"")
if vcpus < 1 or vcpus > 8: if isinstance(vcpus, int) and (vcpus < 1 or vcpus > 8):
raise ValueError(f"vcpus \"{vcpus}\" must match 1 <= vcpus <= 8") raise ValueError(f"vcpus \"{vcpus}\" must match 1 <= vcpus <= 8")
if memory_mb < 512 or memory_mb > 16384: if isinstance(memory_mb, int) and (memory_mb < 512 or memory_mb > 16384):
raise ValueError(f"memory_mb \"{memory_mb}\" must match 512 <= memory_mb <= 16384") raise ValueError(f"memory_mb \"{memory_mb}\" must match 512 <= memory_mb <= 16384")
if not re.match(r"^[a-zA-Z0-9_-]+$", network_name):
raise ValueError(f"network_name \"{network_name}\" must match \"^[a-zA-Z0-9_-]+\"")
if not re.match(r"^[0-9.]+$", public_ipv4):
raise ValueError(f"public_ipv4 \"{public_ipv4}\" must match \"^[0-9.]+$\"")
ssh_keys_string = "\n".join(ssh_authorized_keys) ssh_keys_string = "\n".join(ssh_authorized_keys)
completedProcess = run([ completedProcess = run([
@ -153,7 +168,9 @@ class ShellScriptSpoke(VirtualizationInterface):
template_image_file_name, template_image_file_name,
str(vcpus), str(vcpus),
str(memory_mb), str(memory_mb),
ssh_keys_string ssh_keys_string,
network_name,
public_ipv4
], capture_output=True) ], capture_output=True)
self.validate_completed_process(completedProcess, email) self.validate_completed_process(completedProcess, email)
@ -166,6 +183,8 @@ class ShellScriptSpoke(VirtualizationInterface):
vcpus={str(vcpus)} vcpus={str(vcpus)}
memory={str(memory_mb)} memory={str(memory_mb)}
ssh_authorized_keys={ssh_keys_string} ssh_authorized_keys={ssh_keys_string}
network_name={network_name}
public_ipv4={public_ipv4}
""" """
if not status == "success": if not status == "success":

View File

@ -241,7 +241,6 @@ thead {
background: #bdc7b812; background: #bdc7b812;
} }
td, th { td, th {
padding: 0.1em 1em; padding: 0.1em 1em;
} }
table.small td, table.small th { table.small td, table.small th {

View File

@ -46,7 +46,9 @@
<a href="/payment/stripe">Add funds with Credit/Debit (stripe)</a> <a href="/payment/stripe">Add funds with Credit/Debit (stripe)</a>
<ul><li>notice: stripe will load nonfree javascript </li></ul> <ul><li>notice: stripe will load nonfree javascript </li></ul>
</li> </li>
{% if btcpay_enabled %}
<li><a href="/payment/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li> <li><a href="/payment/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li>
{% endif %}
<li>Cash: email <a href="mailto:treasurer@cyberia.club">treasurer@cyberia.club</a></li> <li>Cash: email <a href="mailto:treasurer@cyberia.club">treasurer@cyberia.club</a></li>
</ul> </ul>

View File

@ -31,7 +31,7 @@
{% if session["account"] %} {% if session["account"] %}
<a href="/console">Capsuls</a> <a href="/console">Capsuls</a>
<a href="/console/keys">SSH &amp; API Keys</a> <a href="/console/ssh">SSH Public Keys</a>
<a href="/console/account-balance">Account Balance</a> <a href="/console/account-balance">Account Balance</a>
{% endif %} {% endif %}

View File

@ -101,7 +101,7 @@
</div> </div>
<div class="row justify-start"> <div class="row justify-start">
<label class="align" for="ssh_authorized_keys">SSH Authorized Keys</label> <label class="align" for="ssh_authorized_keys">SSH Authorized Keys</label>
<a id="ssh_authorized_keys" href="/console/keys">{{ vm['ssh_authorized_keys'] }}</a> <a id="ssh_authorized_keys" href="/console/ssh">{{ vm['ssh_authorized_keys'] }}</a>
</div> </div>
</div> </div>

View File

@ -31,7 +31,7 @@
<p>(At least one month of funding is required)</p> <p>(At least one month of funding is required)</p>
{% elif no_ssh_public_keys %} {% elif no_ssh_public_keys %}
<p>You don't have any ssh public keys yet.</p> <p>You don't have any ssh public keys yet.</p>
<p>You must <a href="/console/keys">upload one</a> before you can create a Capsul.</p> <p>You must <a href="/console/ssh">upload one</a> before you can create a Capsul.</p>
{% elif not capacity_avaliable %} {% elif not capacity_avaliable %}
<p>Host(s) at capacity. No capsuls can be created at this time. sorry. </p> <p>Host(s) at capacity. No capsuls can be created at this time. sorry. </p>
{% else %} {% else %}

View File

@ -23,6 +23,7 @@
How do I log in? How do I log in?
<p>ssh to the ip provided to you using the cyberian user.</p> <p>ssh to the ip provided to you using the cyberian user.</p>
<pre class='code'>$ ssh cyberian@1.2.3.4</pre> <pre class='code'>$ ssh cyberian@1.2.3.4</pre>
<p>For more information, see <a href="/about-ssh">Understanding the Secure Shell Protocol (SSH)</a>.</p>
</li> </li>
<li> <li>
How do I change to the root user? How do I change to the root user?

View File

@ -6,22 +6,38 @@
<div class="row third-margin"> <div class="row third-margin">
<h1>CAPSUL TYPES & PRICING</h1> <h1>CAPSUL TYPES & PRICING</h1>
</div> </div>
<div class="row half-margin">
<table>
<thead>
<tr>
<th>type</th>
<th>monthly*</th>
<th>cpus</th>
<th>mem</th>
<th>ssd</th>
<th>net</th>
</tr>
</thead>
<tbody>
{% for vm_size_key, vm_size in vm_sizes.items() %}
<tr>
<td>{{ vm_size_key }}</td>
<td>${{ vm_size['dollars_per_month'] }}</td>
<td>{{ vm_size['vcpus'] }}</td>
<td>{{ vm_size['memory_mb'] }}</td>
<td>25G</td>
<td>{{ vm_size['bandwidth_gb_per_month'] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="row half-margin"> <div class="row half-margin">
<pre> <pre>
type monthly* cpus mem ssd net*
----- ------- ---- --- --- ---
f1-xs $5.00 1 512M 25G .5TB
f1-s $7.50 1 1024M 25G 1TB
f1-m $12.50 1 2048M 25G 2TB
f1-l $20.00 2 3072M 25G 3TB
f1-x $27.50 3 4096M 25G 4TB
f1-xx $50.00 4 8192M 25G 5TB
* 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 * vms are billed for a minimum of 24 hours upon creation
* all VMs come standard with one public IPv4 address * all VMs come standard with one public IPv4 address
SUPPORTED OPERATING SYSTEMS: SUPPORTED OPERATING SYSTEMS:

View File

@ -1,18 +1,17 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}SSH &amp; API Keys{% endblock %} {% block title %}SSH Public Keys{% endblock %}
{% block content %} {% block content %}
<div class="row third-margin"> <div class="row third-margin">
<h1>SSH PUBLIC KEYS</h1> <h1>SSH PUBLIC KEYS</h1>
</div> </div>
<div class="row third-margin"><div> <div class="row third-margin"><div>
{% if ssh_public_keys|length > 0 %} <hr/> {% endif %} {% if has_ssh_public_keys %} <hr/> {% endif %}
{% for ssh_public_key in ssh_public_keys %} {% for ssh_public_key in ssh_public_keys %}
<form method="post"> <form method="post">
<input type="hidden" name="method" value="DELETE"></input> <input type="hidden" name="method" value="DELETE"></input>
<input type="hidden" name="action" value="delete_ssh_key"></input>
<input type="hidden" name="name" value="{{ ssh_public_key['name'] }}"></input> <input type="hidden" name="name" value="{{ ssh_public_key['name'] }}"></input>
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/> <input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
<div class="row"> <div class="row">
@ -23,14 +22,13 @@
</form> </form>
{% endfor %} {% endfor %}
{% if ssh_public_keys|length > 0 %} <hr/> {% endif %} {% if has_ssh_public_keys %} <hr/> {% endif %}
<div class="third-margin"> <div class="third-margin">
<h1>UPLOAD A NEW SSH PUBLIC KEY</h1> <h1>UPLOAD A NEW SSH PUBLIC KEY</h1>
</div> </div>
<form method="post"> <form method="post">
<input type="hidden" name="method" value="POST"></input> <input type="hidden" name="method" value="POST"></input>
<input type="hidden" name="action" value="upload_ssh_key"></input>
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/> <input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
<div class="row justify-start"> <div class="row justify-start">
<label class="align" for="content">File Contents</label> <label class="align" for="content">File Contents</label>
@ -56,51 +54,6 @@
</div> </div>
</form> </form>
</div></div> </div></div>
<hr/>
<div class="row third-margin">
<h1>API KEYS</h1>
</div>
<div class="row third-margin"><div>
{% if generated_api_token %}
<hr/>
Generated key:
<span class="code">{{ generated_api_token }}</span>
{% endif %}
{% if api_tokens|length >0 %} <hr/>{% endif %}
{% for api_token in api_tokens %}
<form method="post">
<input type="hidden" name="method" value="DELETE"></input>
<input type="hidden" name="action" value="delete_api_token"></input>
<input type="hidden" name="id" value="{{ api_token['id'] }}"></input>
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
<div class="row">
<span class="code">{{ api_token['name'] }}</span>
created {{ api_token['created'].strftime("%b %d %Y") }}
<input type="submit" value="Delete">
</div>
</form>
{% endfor %}
{% if api_tokens|length >0 %} <hr/>{% endif %}
<div class="third-margin">
<h1>GENERATE A NEW API KEY</h1>
</div>
<form method="post">
<input type="hidden" name="method" value="POST"></input>
<input type="hidden" name="action" value="generate_api_token"></input>
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
<div class="smalltext">
<p>Generate a new API key, to integrate with other systems.</p>
</div>
<div class="row justify-start">
<label class="align" for="name">Key Name</label>
<input type="text" id="name" name="name"></input> (defaults to creation time)
</div>
<div class="row justify-end">
<input type="submit" value="Generate">
</div>
</form>
</div></div>
{% endblock %} {% endblock %}
{% block pagesource %}/templates/ssh-public-keys.html{% endblock %} {% block pagesource %}/templates/ssh-public-keys.html{% endblock %}

View File

@ -1,29 +0,0 @@
---
version: "3.8"
services:
app:
image: 3wordchant/capsul-flask:latest
build: .
volumes:
- "./:/app/code"
depends_on:
- db
ports:
- "5000:5000"
environment:
- "POSTGRES_CONNECTION_PARAMETERS=host=db port=5432 user=capsul password=capsul dbname=capsul"
# The image uses gunicorn by default, let's override it with Flask's
# built-in development server
command: ["flask", "run", "-h", "0.0.0.0", "-p", "5000"]
db:
image: "postgres:9.6.5"
volumes:
- "postgres:/var/lib/postgresql/data"
environment:
POSTGRES_USER: capsul
POSTGRES_PASSWORD: capsul
POSTGRES_DB: capsul
volumes:
postgres: