6 Commits

Author SHA1 Message Date
3wc
cffa40c0e3 Fix up API
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-23 23:28:06 +02:00
3wc
6dbae94bd6 Fix typo
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-23 18:08:05 +02:00
3wc
42edcd1c33 Updates for upstream IP handling 2021-07-23 18:07:37 +02:00
3wc
b27d5b3c5b Add basic "create" API..
.. using server-side API tokens
2021-07-23 18:07:35 +02:00
72c04d8495 Docker image, & local development docker-compose.yml (#2)
All checks were successful
continuous-integration/drone/push Build is passing
Adds:
- a Docker image, which can be used in both development and production
- a `docker-compose.yml` file for local development (could probably be adapted for production deployments)

Testing:
- `git checkout docker`
- `docker-compose up`
- pray 🙏
- go to http://localhost:5000

## App architecture

I added the ability to load secret config variables (`HUB_TOKEN`, `STRIPE_SECRET_KEY` etc) from files, to support [Docker Secrets](https://docs.docker.com/engine/swarm/secrets/) natively. The code should probably log an error if a specified `HUB_TOKEN_FILE` doesn't exist instead of failing silently..

## Docker architecture

This uses a multi-stage build to reduce the size of the final image -- having pipenv install to a predefined virtualenv, and then copying that over.

The compose file doesn't include a definition for a cron runner service, and I haven't tested running one yet. Here be dragons!

You can rebuild the image locally using `docker-compose build`, but this isn't required for changes to the app code, only if you edit the `Dockerfile`, or want to publish your image for use on a swarm server (in which case you will need to edit the image name to put in your own Docker hub credentials).

Currently, the image is rebuilt (should set up auto-tagging..) and published with every push to this 3wordchant/capsul-flask fork.

Reviewed-on: #2
Co-authored-by: 3wordchant <3wordchant@noreply.git.autonomic.zone>
Co-committed-by: 3wordchant <3wordchant@noreply.git.autonomic.zone>
2021-07-22 01:18:10 +02:00
5bb76173dd Add custom themes, THEME setting, basic "yolocolo" theme (#7)
Specify `THEME=yourtheme`, add some HTML files in `capsulflask/theme/yourtheme` 👌

We probably want to reduce copypasta in the current `yolocolo` theme by using template inheritance, at some point.

Reviewed-on: #7
Co-authored-by: 3wordchant <3wordchant@noreply.git.autonomic.zone>
Co-committed-by: 3wordchant <3wordchant@noreply.git.autonomic.zone>
2021-07-22 01:15:39 +02:00
26 changed files with 775 additions and 103 deletions

13
.drone.yml Normal file
View File

@ -0,0 +1,13 @@
---
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: ${DRONE_COMMIT_BRANCH}

48
Dockerfile Normal file
View File

@ -0,0 +1,48 @@
FROM python:3.8-alpine as build
RUN apk add --no-cache \
build-base \
gcc \
gettext \
git \
jpeg-dev \
libffi-dev \
libjpeg \
musl-dev \
postgresql-dev \
python3-dev \
zlib-dev
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 \
cloud-utils \
libjpeg \
libpq \
libstdc++ \
libvirt-client \
openssh-client \
virt-install
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

@ -2,6 +2,7 @@ import logging
from logging.config import dictConfig as logging_dict_config from logging.config import dictConfig as logging_dict_config
import atexit import atexit
import jinja2
import os import os
import hashlib import hashlib
import requests import requests
@ -26,8 +27,24 @@ class StdoutMockFlaskMail:
def send(self, message: Message): def send(self, message: Message):
current_app.logger.info(f"Email would have been sent if configured:\n\nto: {','.join(message.recipients)}\nsubject: {message.subject}\nbody:\n\n{message.body}\n\n") current_app.logger.info(f"Email would have been sent if configured:\n\nto: {','.join(message.recipients)}\nsubject: {message.subject}\nbody:\n\n{message.body}\n\n")
load_dotenv(find_dotenv()) load_dotenv(find_dotenv())
for var_name in [
"SPOKE_HOST_TOKEN", "HUB_TOKEN", "STRIPE_SECRET_KEY",
"BTCPAY_PRIVATE_KEY", "MAIL_PASSWORD"
]:
var = os.environ.get(f"{var_name}_FILE")
if not var:
continue
if not os.path.isfile(var):
continue
with open(var) as secret_file:
os.environ[var_name] = secret_file.read().rstrip('\n')
del os.environ[f"{var_name}_FILE"]
app = Flask(__name__) app = Flask(__name__)
app.config.from_mapping( app.config.from_mapping(
@ -71,7 +88,9 @@ 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="") BTCPAY_URL=os.environ.get("BTCPAY_URL", default=""),
THEME=os.environ.get("THEME", 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'])
@ -154,8 +173,15 @@ 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['THEME'] != "":
my_loader = jinja2.ChoiceLoader([
jinja2.FileSystemLoader(
[os.path.join('capsulflask', 'theme', app.config['THEME']),
'capsulflask/templates']),
])
app.jinja_loader = my_loader
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()
@ -177,7 +203,9 @@ 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 auth, landing, console, payment, metrics, cli, hub_api, admin from capsulflask import (
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)
@ -187,13 +215,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

@ -1,3 +1,4 @@
from base64 import b64decode
import functools import functools
import re import re
@ -24,6 +25,15 @@ 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"))

View File

@ -1,7 +1,9 @@
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
@ -98,7 +100,6 @@ 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"
@ -188,6 +189,70 @@ def detail(id):
duration=duration duration=duration
) )
def _create(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 = 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=posted_keys
)
return id, errors
return None, errors
@bp.route("/create", methods=("GET", "POST")) @bp.route("/create", methods=("GET", "POST"))
@account_required @account_required
@ -210,64 +275,13 @@ def create():
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(
size = request.form["size"] vm_sizes,
os = request.form["os"] operating_systems,
if not size: public_keys_for_account,
errors.append("Size is required") affordable_vm_sizes,
elif size not in vm_sizes: request.form)
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_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}")
@ -290,23 +304,25 @@ def create():
vm_sizes=affordable_vm_sizes vm_sizes=affordable_vm_sizes
) )
@bp.route("/ssh", methods=("GET", "POST")) @bp.route("/keys", methods=("GET", "POST"))
@account_required @account_required
def ssh_public_keys(): def ssh_api_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")
method = request.form["method"] action = request.form["action"]
if action == 'upload_ssh_key':
content = None content = None
if method == "POST":
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()
@ -317,7 +333,6 @@ def ssh_public_keys():
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:
@ -330,24 +345,36 @@ def ssh_public_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 method == "DELETE": elif action == "delete_ssh_key":
if len(errors) == 0:
get_model().delete_ssh_public_key(session["account"], name) 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: for error in errors:
flash(error) flash(error)
keys_list=list(map( ssh_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(
"ssh-public-keys.html", "keys.html",
csrf_token = session["csrf-token"], csrf_token = session["csrf-token"],
ssh_public_keys=keys_list, api_tokens=api_tokens_list,
has_ssh_public_keys=len(keys_list) > 0 ssh_public_keys=ssh_keys_list,
generated_api_token=token,
) )
def get_vms(): def get_vms():
@ -371,7 +398,6 @@ 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)
@ -384,7 +410,6 @@ 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':

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.")
exit(1) continue
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 = 18 desiredSchemaVersion = 19
cursor = connection.cursor() cursor = connection.cursor()
@ -128,4 +128,3 @@ 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,8 +1,8 @@
import re 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
@ -17,7 +17,6 @@ class DBModel:
self.cursor = cursor self.cursor = cursor
# ------ LOGIN --------- # ------ LOGIN ---------
@ -44,6 +43,16 @@ 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()
@ -132,6 +141,32 @@ 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
@ -479,8 +514,3 @@ class DBModel:
#cursor.close() #cursor.close()
return to_return return to_return

View File

@ -44,6 +44,7 @@ class MockHub(VirtualizationInterface):
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( get_model().create_vm(
email=email, email=email,
id=id, id=id,

49
capsulflask/publicapi.py Normal file
View File

@ -0,0 +1,49 @@
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)
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
request.json['ssh_authorized_key_count'] = 1
id, errors = _create(
vm_sizes,
operating_systems,
public_keys_for_account,
affordable_vm_sizes,
request.json)
if id is not None:
return jsonify(
id=id,
)
return jsonify(errors=errors)

View File

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

View File

@ -0,0 +1,9 @@
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 = 19;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,35 @@
html {
color: #241e1e !important;
background-color: #bdc7b8 !important;
}
a {
color:#00517a !important;
text-shadow: 1px 1px 0px #eee !important;
}
a:hover, a:active, a:visited {
color: #323417 !important;
}
.nav-links a {
text-shadow: 2px 2px 0px #eee !important;
}
h1, h2, h3, h4, h5 {
text-shadow: 2px 2px 0px #eee;
}
main {
border: 1px dashed #241e1e !important;
}
input, select, textarea {
color: #241e1e !important;
}
th {
border-right: 1px solid #eee !important;
text-align: left !important;
}

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/ssh">SSH Public Keys</a> <a href="/console/keys">SSH &amp; API 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/ssh">{{ vm['ssh_authorized_keys'] }}</a> <a id="ssh_authorized_keys" href="/console/keys">{{ 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/ssh">upload one</a> before you can create a Capsul.</p> <p>You must <a href="/console/keys">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

@ -1,17 +1,18 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}SSH Public Keys{% endblock %} {% block title %}SSH &amp; API 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 has_ssh_public_keys %} <hr/> {% endif %} {% if ssh_public_keys|length > 0 %} <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">
@ -22,13 +23,14 @@
</form> </form>
{% endfor %} {% endfor %}
{% if has_ssh_public_keys %} <hr/> {% endif %} {% if ssh_public_keys|length > 0 %} <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>
@ -54,6 +56,51 @@
</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

@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% block title %}Account Balance{% endblock %}
{% block content %}
<div class="row third-margin">
<h1>Account Balance: ${{ account_balance }}</h1>
</div>
<div class="half-margin">
{% if has_vms and has_payments and warning_text != "" %}
<div class="row">
<pre class="wrap">{{ warning_text }}</pre>
</div>
{% endif %}
<div class="row">
{% if has_payments %}
<div>
<div class="row third-margin">
<h1>Payments</h1>
</div>
<table>
<thead>
<tr>
<th>amount</th>
<th>date</th>
</tr>
</thead>
<tbody>
{% for payment in payments %}
<tr>
<td class="{{ payment['class_name'] }}">${{ payment["dollars"] }}</td>
<td class="{{ payment['class_name'] }}">{{ payment["created"] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<ul>
<li>
<h1>PAYMENT OPTIONS</h1>
<ul>
<li>
<a href="/payment/stripe">Add funds with Credit/Debit (stripe)</a>
<ul><li>notice: stripe will load nonfree javascript </li></ul>
</li>
{% if btcpay_enabled %}
<li><a href="/payment/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li>
{% endif %}
</ul>
</li>
</ul>
</div>
{% if has_vms %}
<div class="row third-margin">
<h1>Capsuls Billed</h1>
</div>
<div class="row">
<table class="small">
<thead>
<tr>
<th>id</th>
<th>created</th>
<th>deleted</th>
<th>$/month</th>
<th>months</th>
<th>$ billed</th>
</tr>
</thead>
<tbody>
{% for vm in vms_billed %}
<tr>
<td>{{ vm["id"] }}</td>
<td>{{ vm["created"] }}</td>
<td>{{ vm["deleted"] }}</td>
<td>${{ vm["dollars_per_month"] }}</td>
<td>{{ vm["months"] }}</td>
<td>${{ vm["dollars"] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endblock %}
{% block pagesource %}/templates/create-capsul.html{% endblock %}

View File

@ -0,0 +1,60 @@
<html lang="en">
<head>
<!-- Namecoin Address: N2aVL6pHtBp7EtNGb3jpsL2L2NyjBNbiB1 -->
<link href="{{ url_for('static', filename='favicon.yolocolo.ico') }}" rel="icon">
<title>{% block title %}{% endblock %}{% if self.title() %} - {% endif %}Capsul</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="Description" content="Cyberia Capsul">
{% block head %}{% endblock %}
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.yolocolo.css') }}">
</head>
<body>
<nav>
<div class="row justify-space-between half-margin">
<div>
🦉 <a href="/"><b>YOLOCOLO</b></a>
</div>
<div>
&nbsp;
{% if session["account"] %}
{ {{ session["account"] }} <a href="{{ url_for('auth.logout') }}">Log Out</a> }
{% else %}
<a href="{{ url_for('auth.login') }}">Login</a>
{% endif %}
</div>
</div>
<div class="row justify-center half-margin wrap nav-links">
<a href="/pricing">Pricing</a>
<a href="/faq">FAQ</a>
{% if session["account"] %}
<a href="/console">Capsuls</a>
<a href="/console/ssh">SSH Public Keys</a>
<a href="/console/account-balance">Account Balance</a>
{% endif %}
<a href="/support">Support</a>
</div>
</nav>
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% block custom_flash %}{% endblock %}
<main>
{% block content %}{% endblock %}
</main>
{% block subcontent %}{% endblock %}
<footer>
This server runs <a
href="https://giit.cyberia.club/~forest/capsul-flask">capsul-flask</a> by
Cyberia Computer Club, available under the <a
href="https://creativecommons.org/licenses/by-sa/4.0/">Attribution-ShareAlike
4.0 International</a> licence.<br/><br/>
<a href="https://git.autonomic.zone/3wordchant/capsul-flask/src/branch/yolocolo/capsulflask{% block pagesource %}{% endblock %}">View page source</a>
</footer>
</body>
</html>

View File

@ -0,0 +1,68 @@
{% extends 'base.html' %}
{% block title %}Capsuls{% endblock %}
{% block custom_flash %}
{% if created %}
<div class="flash green">{{ created }} successfully created!</div>
{% endif %}
{% endblock %}
{% block content %}
<div class="row third-margin">
<h1>Capsuls</h1>
</div>
<div class="third-margin">
{% if has_vms %}
<div class="row third-margin justify-end">
<a href="/console/create">Create Capsul</a>
</div>
<div class="row">
<table>
<thead>
<tr>
<th class="heart-icon"></th>
<th>id</th>
<th>size</th>
<th>cpu</th>
<th>mem</th>
<th>ipv4</th>
<th>os</th>
<th>created</th>
</tr>
</thead>
<tbody>
{% for vm in vms %}
<tr>
{% if vm['state'] == 'starting' or vm['state'] == 'stopping' %}
<td class="capsul-status waiting-pulse"></td>
{% elif vm['state'] == 'crashed' or vm['state'] == 'blocked' or vm['state'] == 'stopped' %}
<td class="capsul-status red"></td>
{% elif vm['state'] == 'unknown' %}
<td class="capsul-status-questionmark">?</td>
{% else %}
<td class="capsul-status green"></td>
{% endif %}
<td><a class="no-shadow" href="/console/{{ vm['id'] }}">{{ vm["id"] }}</a></td>
<td>{{ vm["size"] }}</td>
<td class="metrics"><img src="/metrics/cpu/{{ vm['id'] }}/5m/s"/></td>
<td class="metrics"><img src="/metrics/memory/{{ vm['id'] }}/5m/s"/></td>
<td class="{{ vm['ipv4_status'] }}">{{ vm["ipv4"] }}</td>
<td>{{ vm["os"] }}</td>
<td>{{ vm["created"] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="row">
<p>You don't have any Capsuls running. <a href="/console/create">Create one</a> today!</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block pagesource %}/templates/capsuls.html{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends 'base.html' %}
{% block title %}FAQ{% endblock %}
{% block content %}
<div class="row full-margin"><h1>Frequently Asked Questions</h1></div>
{% endblock %}
{% block subcontent %}
<p>
<ul>
<li>
What is this?
<p>
This is a <strong>technical demo</strong> of <a
href="https://giit.cyberia.club/~forest/capsul-flask">Capsul</a>, for the
as-yet-untitled <a href="https://coops.tech">Cotech</a> server hosting
initiative, which you can <a
href="https://community.coops.tech/t/call-for-input-v2-co-op-vps-survey/2802/9">read
about on the Cotech forum</a>.
</p>
</li>
<li>
What do you mean, "technical demo"?
<p>No backups</p>
<p>No service level agreement</p>
<p>"Best effort" support</p>
</li>
<li>
Where can I get this, but, more reliable?
<p>Cyberia, the authors of this platform, run the canonical instance, <a
href="https://capsul.org">Capsul.org</a>, on hardware they own. Please
send them your money! (cash, crypto, or card accepted).</p>
</li>
<li>
How do I use this system?
<p>Please see <a href="https://capsul.org/faq">the official Capsul FAQ
page</a>.</p>
</li>
</ul>
</p>
{% endblock %}
{% block pagesource %}/templates/faq.html{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<h1>
<pre>
_ _
_ _ ___ | | ___ ___ ___ | | ___
| | | |/ _ \| |/ _ \ / __/ _ \| |/ _ \
| |_| | (_) | | (_) | (_| (_) | | (_) |
\__, |\___/|_|\___/ \___\___/|_|\___/
|___/
</pre>
<span>Co-operative hosting using <a href="https://cyberia.club">Cyberia</a>'s Capsul</span>
{% endblock %}
{% block subcontent %}
<p>
<ul>
<li>Sign up for an account!</li>
<li>Add some funds!</li>
<li>Create a VPS!</li>
<li>Give your feedback!</li>
</ul>
</p>
{% endblock %}
{% block pagesource %}/templates/index.html{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% block title %}Pricing{% endblock %}
{% block content %}
<div class="row third-margin">
<h1>CAPSUL TYPES & PRICING</h1>
</div>
<div class="row half-margin">
<p>
Rates for this service aren't set yet. You can see Cyberia's Capsul pricing
on <a href="https://capsul.org/pricing">their website</a>.
</p>
</div>
<div>
<pre>
SUPPORTED OPERATING SYSTEMS:
{% for os_id, os in operating_systems.items() %} - {{ os.description }}
{% endfor %}
</pre>
</div>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% block title %}Support{% endblock %}
{% block content %}
<div class="row half-margin">
<h1>SUPPORT</h1>
</div>
<div class="row half-margin">
<a href="mailto:yolocolo@doesthisthing.work?subject=Please%20help!">yolocolo@doesthisthing.work</a>
</div>
{% endblock %}
{% block subcontent %}
<p>
You can also find us on Matrix: <a
href="https://matrix.to/#/#untitled-hosting.public:autonomic.zone">#untitled-hosting.public:autonomic.zone</a>.
</p>
{% endblock %}
{% block pagesource %}/templates/support.html{% endblock %}

36
docker-compose.yml Normal file
View File

@ -0,0 +1,36 @@
---
version: "3.8"
services:
app:
image: 3wordchant/capsul-flask:latest
build: .
volumes:
- "./:/app/code"
- "../tank:/tank"
# - "/var/run/libvirt/libvirt-sock:/var/run/libvirt/libvirt-sock"
depends_on:
- db
ports:
- "5000:5000"
environment:
- "POSTGRES_CONNECTION_PARAMETERS=host=db port=5432 user=capsul password=capsul dbname=capsul"
- SPOKE_MODEL=shell-scripts
#- FLASK_DEBUG=1
- BASE_URL=http://localhost:5000
- ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES=3wc.capsul@doesthisthing.work
- VIRSH_DEFAULT_CONNECT_URI=qemu:///system
# 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-alpine"
volumes:
- "postgres:/var/lib/postgresql/data"
environment:
POSTGRES_USER: capsul
POSTGRES_PASSWORD: capsul
POSTGRES_DB: capsul
volumes:
postgres: