Compare commits
24 Commits
cfb323bf60
...
yolocolo
Author | SHA1 | Date | |
---|---|---|---|
7ae5071cef | |||
d4a9f2f40a | |||
0ae55712ac | |||
8634cda388 | |||
5f868de9cc | |||
d238bc9551 | |||
17c915c1bf | |||
8a6d558402 | |||
ff4e63339f | |||
7b16606b16 | |||
d9f3e68278 | |||
bcd1190f50 | |||
6963e22933 | |||
816be36a52 | |||
d6f2f6d0bf | |||
cffa40c0e3 | |||
6dbae94bd6 | |||
42edcd1c33 | |||
b27d5b3c5b | |||
72c04d8495 | |||
5bb76173dd | |||
8c0c613392 | |||
50ee1144f9 | |||
08eb38dc57 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
notes.txt
|
notes.txt
|
||||||
.env
|
.env
|
||||||
|
.env.bak
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
*.pyc
|
*.pyc
|
||||||
|
@ -58,6 +58,7 @@ app.config.from_mapping(
|
|||||||
LOG_LEVEL=os.environ.get("LOG_LEVEL", default="INFO"),
|
LOG_LEVEL=os.environ.get("LOG_LEVEL", default="INFO"),
|
||||||
SPOKE_HOST_ID=os.environ.get("SPOKE_HOST_ID", default="baikal"),
|
SPOKE_HOST_ID=os.environ.get("SPOKE_HOST_ID", default="baikal"),
|
||||||
SPOKE_HOST_TOKEN=os.environ.get("SPOKE_HOST_TOKEN", default="changeme"),
|
SPOKE_HOST_TOKEN=os.environ.get("SPOKE_HOST_TOKEN", default="changeme"),
|
||||||
|
SSH_USERNAME=os.environ.get("SSH_USERNAME", default="cyberian"),
|
||||||
HUB_TOKEN=os.environ.get("HUB_TOKEN", default="changeme"),
|
HUB_TOKEN=os.environ.get("HUB_TOKEN", default="changeme"),
|
||||||
|
|
||||||
# https://www.postgresql.org/docs/9.1/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS
|
# https://www.postgresql.org/docs/9.1/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS
|
||||||
@ -182,7 +183,6 @@ if app.config['THEME'] != "":
|
|||||||
app.jinja_loader = my_loader
|
app.jinja_loader = my_loader
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
@ -204,7 +204,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)
|
||||||
@ -214,13 +216,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:
|
||||||
|
@ -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"))
|
||||||
|
|
||||||
@ -56,7 +66,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)
|
||||||
|
@ -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
|
||||||
@ -27,12 +29,14 @@ def make_capsul_id():
|
|||||||
letters_n_nummers = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10)
|
letters_n_nummers = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10)
|
||||||
return f"capsul-{letters_n_nummers}"
|
return f"capsul-{letters_n_nummers}"
|
||||||
|
|
||||||
def double_check_capsul_address(id, ipv4, get_ssh_host_keys):
|
def double_check_capsul_address(id, ipv4, ipv6, get_ssh_host_keys):
|
||||||
try:
|
try:
|
||||||
result = current_app.config["HUB_MODEL"].get(id, get_ssh_host_keys)
|
result = current_app.config["HUB_MODEL"].get(id, get_ssh_host_keys)
|
||||||
if result != None and result.ipv4 != None and result.ipv4 != ipv4:
|
if result != None and result.ipv4 != None and result.ipv4 != ipv4:
|
||||||
ipv4 = result.ipv4
|
get_model().update_vm_ipv4(email=session["account"], id=id, ipv4=result.ipv4)
|
||||||
get_model().update_vm_ip(email=session["account"], id=id, ipv4=result.ipv4)
|
|
||||||
|
if result != None and result.ipv6 != None and result.ipv6 != ipv6:
|
||||||
|
get_model().update_vm_ipv6(email=session["account"], id=id, ipv6=result.ipv6)
|
||||||
|
|
||||||
if result != None and result.ssh_host_keys != None and get_ssh_host_keys:
|
if result != None and result.ssh_host_keys != None and get_ssh_host_keys:
|
||||||
get_model().update_vm_ssh_host_keys(email=session["account"], id=id, ssh_host_keys=result.ssh_host_keys)
|
get_model().update_vm_ssh_host_keys(email=session["account"], id=id, ssh_host_keys=result.ssh_host_keys)
|
||||||
@ -59,36 +63,37 @@ def index():
|
|||||||
# for now we are going to check the IP according to the virt model
|
# for now we are going to check the IP according to the virt model
|
||||||
# on every request. this could be done by a background job and cached later on...
|
# on every request. this could be done by a background job and cached later on...
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
result = double_check_capsul_address(vm["id"], vm["ipv4"], False)
|
result = double_check_capsul_address(vm["id"], vm["ipv4"], vm["ipv6"], False)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
vm["ipv4"] = result.ipv4
|
vm["ipv4"] = result.ipv4
|
||||||
|
vm["ipv6"] = result.ipv6
|
||||||
vm["state"] = result.state
|
vm["state"] = result.state
|
||||||
else:
|
else:
|
||||||
vm["state"] = "unknown"
|
vm["state"] = "unknown"
|
||||||
|
|
||||||
|
|
||||||
mappedVms = []
|
mappedVms = []
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
ip_display = vm['ipv4']
|
ip_display = {}
|
||||||
if not ip_display:
|
ip_display_class = {}
|
||||||
if vm["state"] == "running":
|
for af in ['ipv4', 'ipv6']:
|
||||||
ip_display = "..booting.."
|
ip_display[af] = vm[af]
|
||||||
else:
|
ip_display_class[af] = "ok"
|
||||||
ip_display = "unknown"
|
if not ip_display[af]:
|
||||||
|
if vm["state"] == "running":
|
||||||
ip_display_class = "ok"
|
ip_display[af] = "..booting.."
|
||||||
if not vm['ipv4']:
|
ip_display_class[af] = "waiting-pulse"
|
||||||
if vm["state"] == "running":
|
else:
|
||||||
ip_display_class = "waiting-pulse"
|
ip_display[af] = "unknown"
|
||||||
else:
|
ip_display_class[af] = "yellow"
|
||||||
ip_display_class = "yellow"
|
|
||||||
|
|
||||||
mappedVms.append(dict(
|
mappedVms.append(dict(
|
||||||
id=vm['id'],
|
id=vm['id'],
|
||||||
size=vm['size'],
|
size=vm['size'],
|
||||||
state=vm['state'],
|
state=vm['state'],
|
||||||
ipv4=ip_display,
|
ipv4=ip_display['ipv4'],
|
||||||
ipv4_status=ip_display_class,
|
ipv4_status=ip_display_class['ipv4'],
|
||||||
|
ipv6=ip_display['ipv6'],
|
||||||
|
ipv6_status=ip_display_class['ipv6'],
|
||||||
os=vm['os'],
|
os=vm['os'],
|
||||||
created=vm['created'].strftime("%b %d %Y")
|
created=vm['created'].strftime("%b %d %Y")
|
||||||
))
|
))
|
||||||
@ -98,7 +103,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"
|
||||||
@ -108,6 +112,8 @@ def detail(id):
|
|||||||
if vm is None:
|
if vm is None:
|
||||||
return abort(404, f"{id} doesn't exist.")
|
return abort(404, f"{id} doesn't exist.")
|
||||||
|
|
||||||
|
vm['ssh_username'] = current_app.config['SSH_USERNAME']
|
||||||
|
|
||||||
if vm['deleted']:
|
if vm['deleted']:
|
||||||
return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True)
|
return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True)
|
||||||
|
|
||||||
@ -167,10 +173,11 @@ def detail(id):
|
|||||||
else:
|
else:
|
||||||
needs_ssh_host_keys = "ssh_host_keys" not in vm or len(vm["ssh_host_keys"]) == 0
|
needs_ssh_host_keys = "ssh_host_keys" not in vm or len(vm["ssh_host_keys"]) == 0
|
||||||
|
|
||||||
vm_from_virt_model = double_check_capsul_address(vm["id"], vm["ipv4"], needs_ssh_host_keys)
|
vm_from_virt_model = double_check_capsul_address(vm["id"], vm["ipv4"], vm['ipv6'], needs_ssh_host_keys)
|
||||||
|
|
||||||
if vm_from_virt_model is not None:
|
if vm_from_virt_model is not None:
|
||||||
vm["ipv4"] = vm_from_virt_model.ipv4
|
vm["ipv4"] = vm_from_virt_model.ipv4
|
||||||
|
vm["ipv6"] = vm_from_virt_model.ipv6
|
||||||
vm["state"] = vm_from_virt_model.state
|
vm["state"] = vm_from_virt_model.state
|
||||||
if needs_ssh_host_keys:
|
if needs_ssh_host_keys:
|
||||||
vm["ssh_host_keys"] = vm_from_virt_model.ssh_host_keys
|
vm["ssh_host_keys"] = vm_from_virt_model.ssh_host_keys
|
||||||
@ -188,6 +195,70 @@ def detail(id):
|
|||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _create(email, vm_sizes, operating_systems, public_keys_for_account, affordable_vm_sizes, server_data):
|
||||||
|
errors = list()
|
||||||
|
|
||||||
|
size = server_data.get("size")
|
||||||
|
os = server_data.get("os")
|
||||||
|
posted_keys_count = int(server_data.get("ssh_authorized_key_count"))
|
||||||
|
|
||||||
|
if not size:
|
||||||
|
errors.append("Size is required")
|
||||||
|
elif size not in vm_sizes:
|
||||||
|
errors.append(f"Invalid size {size}")
|
||||||
|
elif size not in affordable_vm_sizes:
|
||||||
|
errors.append(f"Your account must have enough credit to run an {size} for 1 month before you will be allowed to create it")
|
||||||
|
|
||||||
|
|
||||||
|
if not os:
|
||||||
|
errors.append("OS is required")
|
||||||
|
elif os not in operating_systems:
|
||||||
|
errors.append(f"Invalid os {os}")
|
||||||
|
|
||||||
|
posted_keys = list()
|
||||||
|
|
||||||
|
if posted_keys_count > 1000:
|
||||||
|
errors.append("something went wrong with ssh keys")
|
||||||
|
else:
|
||||||
|
for i in range(0, posted_keys_count):
|
||||||
|
if f"ssh_key_{i}" in server_data:
|
||||||
|
posted_name = server_data.get(f"ssh_key_{i}")
|
||||||
|
key = None
|
||||||
|
for x in public_keys_for_account:
|
||||||
|
if x['name'] == posted_name:
|
||||||
|
key = x
|
||||||
|
if key:
|
||||||
|
posted_keys.append(key)
|
||||||
|
else:
|
||||||
|
errors.append(f"SSH Key \"{posted_name}\" doesn't exist")
|
||||||
|
|
||||||
|
if len(posted_keys) == 0:
|
||||||
|
errors.append("At least one SSH Public Key is required")
|
||||||
|
|
||||||
|
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(
|
||||||
|
vm_sizes[size]['memory_mb']*1024*1024
|
||||||
|
)
|
||||||
|
|
||||||
|
if not capacity_avaliable:
|
||||||
|
errors.append("""
|
||||||
|
host(s) at capacity. no capsuls can be created at this time. sorry.
|
||||||
|
""")
|
||||||
|
|
||||||
|
if len(errors) == 0:
|
||||||
|
id = make_capsul_id()
|
||||||
|
current_app.config["HUB_MODEL"].create(
|
||||||
|
email = email,
|
||||||
|
id=id,
|
||||||
|
os=os,
|
||||||
|
size=size,
|
||||||
|
template_image_file_name=operating_systems[os]['template_image_file_name'],
|
||||||
|
vcpus=vm_sizes[size]['vcpus'],
|
||||||
|
memory_mb=vm_sizes[size]['memory_mb'],
|
||||||
|
ssh_authorized_keys=list(map(lambda x: dict(name=x['name'], content=x['content']), posted_keys))
|
||||||
|
)
|
||||||
|
return id, errors
|
||||||
|
|
||||||
|
return None, errors
|
||||||
|
|
||||||
@bp.route("/create", methods=("GET", "POST"))
|
@bp.route("/create", methods=("GET", "POST"))
|
||||||
@account_required
|
@account_required
|
||||||
@ -199,67 +270,6 @@ def create():
|
|||||||
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()
|
errors = list()
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
|
|
||||||
return abort(418, f"u want tea")
|
|
||||||
|
|
||||||
size = request.form["size"]
|
|
||||||
os = request.form["os"]
|
|
||||||
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_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:
|
|
||||||
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}")
|
|
||||||
|
|
||||||
affordable_vm_sizes = dict()
|
affordable_vm_sizes = dict()
|
||||||
for key, vm_size in vm_sizes.items():
|
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,
|
# if a user deposits $7.50 and then creates an f1-s vm which costs 7.50 a month,
|
||||||
@ -268,6 +278,20 @@ def create():
|
|||||||
if vm_size["dollars_per_month"] <= account_balance+0.25:
|
if vm_size["dollars_per_month"] <= account_balance+0.25:
|
||||||
affordable_vm_sizes[key] = vm_size
|
affordable_vm_sizes[key] = vm_size
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
|
||||||
|
return abort(418, f"u want tea")
|
||||||
|
id, errors = _create(
|
||||||
|
session['account'],
|
||||||
|
vm_sizes,
|
||||||
|
operating_systems,
|
||||||
|
public_keys_for_account,
|
||||||
|
affordable_vm_sizes,
|
||||||
|
request.form)
|
||||||
|
if len(errors) == 0:
|
||||||
|
return redirect(f"{url_for('console.index')}?created={id}")
|
||||||
|
|
||||||
|
|
||||||
for error in errors:
|
for error in errors:
|
||||||
flash(error)
|
flash(error)
|
||||||
|
|
||||||
@ -287,23 +311,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"]
|
||||||
content = None
|
|
||||||
if method == "POST":
|
if action == 'upload_ssh_key':
|
||||||
|
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()
|
||||||
@ -311,10 +337,9 @@ def ssh_public_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:
|
||||||
@ -327,24 +352,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":
|
||||||
|
get_model().delete_ssh_public_key(session["account"], name)
|
||||||
|
|
||||||
if len(errors) == 0:
|
elif action == "generate_api_token":
|
||||||
get_model().delete_ssh_public_key(session["account"], name)
|
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():
|
||||||
@ -368,7 +405,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)
|
||||||
@ -381,7 +417,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':
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
@ -144,10 +179,14 @@ class DBModel:
|
|||||||
self.cursor.fetchall()
|
self.cursor.fetchall()
|
||||||
))
|
))
|
||||||
|
|
||||||
def update_vm_ip(self, email, id, ipv4):
|
def update_vm_ipv4(self, email, id, ipv4):
|
||||||
self.cursor.execute("UPDATE vms SET public_ipv4 = %s WHERE email = %s AND id = %s", (ipv4, email, id))
|
self.cursor.execute("UPDATE vms SET public_ipv4 = %s WHERE email = %s AND id = %s", (ipv4, email, id))
|
||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
|
|
||||||
|
def update_vm_ipv6(self, email, id, ipv6):
|
||||||
|
self.cursor.execute("UPDATE vms SET public_ipv6 = %s WHERE email = %s AND id = %s", (ipv6, email, id))
|
||||||
|
self.connection.commit()
|
||||||
|
|
||||||
def update_vm_ssh_host_keys(self, email, id, ssh_host_keys):
|
def update_vm_ssh_host_keys(self, email, id, ssh_host_keys):
|
||||||
for key in ssh_host_keys:
|
for key in ssh_host_keys:
|
||||||
self.cursor.execute("""
|
self.cursor.execute("""
|
||||||
@ -479,8 +518,3 @@ class DBModel:
|
|||||||
#cursor.close()
|
#cursor.close()
|
||||||
|
|
||||||
return to_return
|
return to_return
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,
|
||||||
@ -197,6 +198,10 @@ class CapsulFlaskHub(VirtualizationInterface):
|
|||||||
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")
|
||||||
|
|
||||||
|
current_app.logger.error(f'{email}, {id} {os} {size} {template_image_file_name} {vcpus} {memory_mb}')
|
||||||
|
current_app.logger.error(f'{ssh_authorized_keys}')
|
||||||
|
|
||||||
payload = json.dumps(dict(
|
payload = json.dumps(dict(
|
||||||
type="create",
|
type="create",
|
||||||
email=email,
|
email=email,
|
||||||
|
@ -22,7 +22,7 @@ def pricing():
|
|||||||
|
|
||||||
@bp.route("/faq")
|
@bp.route("/faq")
|
||||||
def faq():
|
def faq():
|
||||||
return render_template("faq.html")
|
return render_template("faq.html", ssh_username=current_app.config['SSH_USERNAME'])
|
||||||
|
|
||||||
@bp.route("/about-ssh")
|
@bp.route("/about-ssh")
|
||||||
def about_ssh():
|
def about_ssh():
|
||||||
|
50
capsulflask/publicapi.py
Normal file
50
capsulflask/publicapi.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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"][0]
|
||||||
|
|
||||||
|
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(
|
||||||
|
email,
|
||||||
|
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)
|
2
capsulflask/schema_migrations/19_down_api_tokens.py
Normal file
2
capsulflask/schema_migrations/19_down_api_tokens.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE api_keys;
|
||||||
|
UPDATE schemaversion SET version = 18;
|
9
capsulflask/schema_migrations/19_up_api_tokens.py
Normal file
9
capsulflask/schema_migrations/19_up_api_tokens.py
Normal 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;
|
@ -29,7 +29,8 @@ if virsh domuuid "$vmname" | grep -vqE '^[\t\s\n]*$'; then
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# this gets the ipv4
|
# this gets the vm ip addresses
|
||||||
ipv4="$(virsh domifaddr "$vmname" | awk '/vnet/ {print $4}' | cut -d'/' -f1)"
|
ipv4="$(virsh domifaddr "$vmname" | awk '/ipv4/ {print $4}' | cut -d'/' -f1)"
|
||||||
|
ipv6="$(virsh domifaddr "$vmname" | awk '/ipv6/ {print $4}' | cut -d'/' -f1)"
|
||||||
|
|
||||||
echo "$exists $state $ipv4"
|
echo "$exists $state $ipv4 $ipv6"
|
||||||
|
@ -114,24 +114,30 @@ class ShellScriptSpoke(VirtualizationInterface):
|
|||||||
if len(fields) < 3:
|
if len(fields) < 3:
|
||||||
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state)
|
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state)
|
||||||
|
|
||||||
ipaddr = fields[2]
|
ip4addr = fields[2]
|
||||||
|
|
||||||
if not re.match(r"^([0-9]{1,3}\.){3}[0-9]{1,3}$", ipaddr):
|
if not re.match(r"^([0-9]{1,3}\.){3}[0-9]{1,3}$", ip4addr):
|
||||||
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state)
|
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state)
|
||||||
|
|
||||||
if get_ssh_host_keys:
|
if get_ssh_host_keys:
|
||||||
try:
|
try:
|
||||||
completedProcess2 = run([join(current_app.root_path, 'shell_scripts/ssh-keyscan.sh'), ipaddr], capture_output=True)
|
completedProcess2 = run([join(current_app.root_path, 'shell_scripts/ssh-keyscan.sh'), ip4addr], capture_output=True)
|
||||||
self.validate_completed_process(completedProcess2)
|
self.validate_completed_process(completedProcess2)
|
||||||
ssh_host_keys = json.loads(completedProcess2.stdout.decode("utf-8"))
|
ssh_host_keys = json.loads(completedProcess2.stdout.decode("utf-8"))
|
||||||
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state, ipv4=ipaddr, ssh_host_keys=ssh_host_keys)
|
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state, ipv4=ip4addr, ssh_host_keys=ssh_host_keys)
|
||||||
except:
|
except:
|
||||||
current_app.logger.warning(f"""
|
current_app.logger.warning(f"""
|
||||||
failed to ssh-keyscan {id} at {ipaddr}:
|
failed to ssh-keyscan {id} at {ip4addr}:
|
||||||
{my_exec_info_message(sys.exc_info())}"""
|
{my_exec_info_message(sys.exc_info())}"""
|
||||||
)
|
)
|
||||||
|
|
||||||
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state, ipv4=ipaddr)
|
if len(fields) < 4:
|
||||||
|
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state, ipv4=ip4addr)
|
||||||
|
|
||||||
|
ip6addr = fields[3]
|
||||||
|
|
||||||
|
return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state, ipv4=ip4addr, ipv6=ip6addr)
|
||||||
|
|
||||||
|
|
||||||
def list_ids(self) -> list:
|
def list_ids(self) -> list:
|
||||||
completedProcess = run([join(current_app.root_path, 'shell_scripts/list-ids.sh')], capture_output=True)
|
completedProcess = run([join(current_app.root_path, 'shell_scripts/list-ids.sh')], capture_output=True)
|
||||||
|
@ -49,6 +49,8 @@
|
|||||||
{% if btcpay_enabled %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
<li>Cash: email <a href="mailto:treasurer@cyberia.club">treasurer@cyberia.club</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<div class="row justify-space-between half-margin">
|
<div class="row justify-space-between half-margin">
|
||||||
<div>
|
<div>
|
||||||
🦉 <a href="/"><b>YOLOCOLO</b></a>
|
<a href="/"><b>Capsul</b></a>💊
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
@ -27,10 +27,11 @@
|
|||||||
<div class="row justify-center half-margin wrap nav-links">
|
<div class="row justify-center half-margin wrap nav-links">
|
||||||
<a href="/pricing">Pricing</a>
|
<a href="/pricing">Pricing</a>
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
|
||||||
{% 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 & API Keys</a>
|
||||||
<a href="/console/account-balance">Account Balance</a>
|
<a href="/console/account-balance">Account Balance</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -46,12 +47,11 @@
|
|||||||
</main>
|
</main>
|
||||||
{% block subcontent %}{% endblock %}
|
{% block subcontent %}{% endblock %}
|
||||||
<footer>
|
<footer>
|
||||||
This server runs <a
|
(c) Attribution-ShareAlike 4.0 International <br/>
|
||||||
href="https://giit.cyberia.club/~forest/capsul-flask">capsul-flask</a> by
|
A service by Cyberia Computer Club 2020-<span class="bigtext">∞</span> <br/>
|
||||||
Cyberia Computer Club, available under the <a
|
<br/>
|
||||||
href="https://creativecommons.org/licenses/by-sa/4.0/">Attribution-ShareAlike
|
<br/>
|
||||||
4.0 International</a> licence.<br/><br/>
|
<a href="https://giit.cyberia.club/~forest/capsul-flask/tree/master/capsulflask{% block pagesource %}{% endblock %}">View page source</a>
|
||||||
<a href="https://git.autonomic.zone/3wordchant/capsul-flask/src/branch/yolocolo/capsulflask{% block pagesource %}{% endblock %}">View page source</a>
|
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -79,6 +79,10 @@
|
|||||||
<label class="align" for="ipv4">IPv4 Address</label>
|
<label class="align" for="ipv4">IPv4 Address</label>
|
||||||
<span id="ipv4">{{ vm['ipv4'] }}</span>
|
<span id="ipv4">{{ vm['ipv4'] }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row justify-start">
|
||||||
|
<label class="align" for="ipv6">IPv6 Address</label>
|
||||||
|
<span id="ipv6">{{ vm['ipv6'] }}</span>
|
||||||
|
</div>
|
||||||
<div class="row justify-start">
|
<div class="row justify-start">
|
||||||
<label class="align" for="os_description">Operating System</label>
|
<label class="align" for="os_description">Operating System</label>
|
||||||
<span id="os_description">{{ vm['os_description'] }}</span>
|
<span id="os_description">{{ vm['os_description'] }}</span>
|
||||||
@ -97,11 +101,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row justify-start">
|
<div class="row justify-start">
|
||||||
<label class="align" for="ssh_username">SSH Username</label>
|
<label class="align" for="ssh_username">SSH Username</label>
|
||||||
<span id="ssh_username">cyberian</span>
|
<span id="ssh_username">{{ vm['ssh_username'] }}</span>
|
||||||
</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>
|
||||||
|
@ -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 %}
|
||||||
|
@ -10,32 +10,82 @@
|
|||||||
<p>
|
<p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
What is this?
|
Which instance type should I buy?
|
||||||
<p>
|
<p>There are no hard rules for this sort of thing, but here are some guidelines:</p>
|
||||||
This is a <strong>technical demo</strong> of <a
|
<p>f1-xs: blog, vpn, bot, cgit</p>
|
||||||
href="https://giit.cyberia.club/~forest/capsul-flask">Capsul</a>, for the
|
<p>f1-s: a bot, owncloud, gitea, popular blog</p>
|
||||||
as-yet-untitled <a href="https://coops.tech">Cotech</a> server hosting
|
<p>f1-m: docker host, build system</p>
|
||||||
initiative, which you can <a
|
<p>f1-l: large webservice, rotund java app</p>
|
||||||
href="https://community.coops.tech/t/call-for-input-v2-co-op-vps-survey/2802/9">read
|
<p>f1-x: gitlab (wow such memory very devops</p>
|
||||||
about on the Cotech forum</a>.
|
<p>f1-xx: something gargantuan</p>
|
||||||
</p>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
What do you mean, "technical demo"?
|
How do I log in?
|
||||||
<p>No backups</p>
|
<p>ssh to the ip provided to you using the "{{ ssh_username }}" user.</p>
|
||||||
<p>No service level agreement</p>
|
<pre class='code'>$ ssh {{ ssh_username }}@1.2.3.4</pre>
|
||||||
<p>"Best effort" support</p>
|
<p>For more information, see <a href="/about-ssh">Understanding the Secure Shell Protocol (SSH)</a>.</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Where can I get this, but, more reliable?
|
How do I change to the root user?
|
||||||
<p>Cyberia, the authors of this platform, run the canonical instance, <a
|
<p>The "{{ ssh_username }}" user has passwordless sudo access by default. This should work:</p>
|
||||||
href="https://capsul.org">Capsul.org</a>, on hardware they own. Please
|
<pre class='code'>
|
||||||
send them your money! (cash, crypto, or card accepted).</p>
|
# Linux
|
||||||
|
$ sudo su -
|
||||||
|
|
||||||
|
# OpenBSD
|
||||||
|
$ doas su -</pre>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
How do I use this system?
|
Do you offer reverse DNS?
|
||||||
<p>Please see <a href="https://capsul.org/faq">the official Capsul FAQ
|
<p>We do, but right now it's a manual process. Shoot us an email and we'll get it done.</p>
|
||||||
page</a>.</p>
|
</li>
|
||||||
|
<li>
|
||||||
|
What if I don't pay / don't maintain my payments?
|
||||||
|
<p>Your VM will eventually be deleted.
|
||||||
|
Capsul will send you a few inoffensive reminders as that termination date approaches.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Besides my virtual machines and payments, what information do you keep about me?
|
||||||
|
<p>We associate an email address with every VM so that we can track payment and respond to support requests.</p>
|
||||||
|
<p>If you pay with a credit card, Stripe stores some additional details about you that we literally cannot delete.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
What can I do with my VM?
|
||||||
|
<p>Make it into a mailserver, a tor relay, a VPN host, whatever you'd like - we do have one small request, though.</p>
|
||||||
|
<p>Crypto mining on capsul is currently considered obnoxious behavior, because the hashrates on our CPUs is so low and because mining crypto consumes entire processor cores that could have otherwise been shared between many dozens of other users.</p>
|
||||||
|
<p>In the future, if we have plentiful CPU resources, we may come out with a tier more suitable for mining - maybe a high cpu tier or similar, where each VM gets a full dedicated core and sharing them is not anticipated.</p>
|
||||||
|
<p>We will never snoop on your traffic or inspect what's going on inside of our customer virtual machines - we don't want to. We hope that you'll extend us a similar courtesy and try not to use too much of our shared CPU resources. Capsul is currently a shared (resource-wise) world, and we all must live in it together!</p>
|
||||||
|
<p>Also, mandatory: our systems exist within the USA, and as such those systems are bound by US law.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Can you recover my passwords/insert new keys?
|
||||||
|
<p>Can we? Technically yes. Will we? No, never. It would violate the trust that our users have in us.
|
||||||
|
We have no interest in touching client VMs after they're running.
|
||||||
|
We promise to keep your machines running smoothly.
|
||||||
|
If you lose access to your VM, that's on you.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Do you offer refunds?
|
||||||
|
<p>Not now, but email us and we can probably figure something out.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Where do the VMs run? Is it on a machine that you guys own/control?
|
||||||
|
<p>Capsul runs on a server named Baikal which Cyberia built from scratch & mailed to a datacenter
|
||||||
|
in Georgia called CyberWurx. CyberWurx staff installed it for us in a rack space that
|
||||||
|
Cyberia pays for. </p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Do you offer support?
|
||||||
|
<p>Yep, see <a href="/support">our support page</a>.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Do you have an SLA?
|
||||||
|
<p>No, but we normally respond pretty quickly.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Will you implement feature X?
|
||||||
|
<p>Maybe! Email <a href="mailto:ops@cyberia.club">ops@cyberia.club</a> and ask us about it.</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
|
@ -1,26 +1,31 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>
|
|
||||||
<pre>
|
|
||||||
_ _
|
|
||||||
_ _ ___ | | ___ ___ ___ | | ___
|
|
||||||
| | | |/ _ \| |/ _ \ / __/ _ \| |/ _ \
|
|
||||||
| |_| | (_) | | (_) | (_| (_) | | (_) |
|
|
||||||
\__, |\___/|_|\___/ \___\___/|_|\___/
|
|
||||||
|___/
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>CAPSUL</h1>
|
||||||
|
<pre>
|
||||||
|
.-.
|
||||||
|
/:::\
|
||||||
|
/::::/
|
||||||
|
/ `-:/
|
||||||
|
/ /
|
||||||
|
\ /
|
||||||
|
`"`
|
||||||
</pre>
|
</pre>
|
||||||
<span>Co-operative hosting using <a href="https://cyberia.club">Cyberia</a>'s Capsul</span>
|
<span>Simple, fast, private compute by <a href="https://cyberia.club">cyberia.club</a></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block subcontent %}
|
{% block subcontent %}
|
||||||
<p>
|
<p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Sign up for an account!</li>
|
<li>Low friction: simply log in with your email address and fund your account with Credit/Debit or Cryptocurrency</li>
|
||||||
<li>Add some funds!</li>
|
<li>All root disks are backed up at no charge</li>
|
||||||
<li>Create a VPS!</li>
|
<li>All storage is fast, local, and solid-state</li>
|
||||||
<li>Give your feedback!</li>
|
<li>All network connections are low latency</li>
|
||||||
|
<li>Supported by amazing volunteers from Cyberia</li>
|
||||||
|
<li>Upfront prices, no confusing billing</li>
|
||||||
|
<li>Operated by a Minnesota non-profit organization that will never exploit you</li>
|
||||||
|
<li>We donate a portion of our proceeds to likeminded hacker groups around the globe</li>
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block title %}SSH Public Keys{% endblock %}
|
{% block title %}SSH & 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 %}
|
@ -7,17 +7,42 @@
|
|||||||
<h1>CAPSUL TYPES & PRICING</h1>
|
<h1>CAPSUL TYPES & PRICING</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="row half-margin">
|
<div class="row half-margin">
|
||||||
<p>
|
<table>
|
||||||
Rates for this service aren't set yet. You can see Cyberia's Capsul pricing
|
<thead>
|
||||||
on <a href="https://capsul.org/pricing">their website</a>.
|
<tr>
|
||||||
</p>
|
<th>type</th>
|
||||||
</div>
|
<th>monthly*</th>
|
||||||
<div>
|
<th>cpus</th>
|
||||||
<pre>
|
<th>mem</th>
|
||||||
SUPPORTED OPERATING SYSTEMS:
|
<th>ssd</th>
|
||||||
|
<th>net</th>
|
||||||
{% for os_id, os in operating_systems.items() %} - {{ os.description }}
|
</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 %}
|
{% endfor %}
|
||||||
</pre>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="row half-margin">
|
||||||
|
<pre>
|
||||||
|
* net is calculated as a per-month average
|
||||||
|
* vms are billed for a minimum of 24 hours upon creation
|
||||||
|
* all VMs come standard with one public IPv4 address
|
||||||
|
|
||||||
|
SUPPORTED OPERATING SYSTEMS:
|
||||||
|
|
||||||
|
|
||||||
|
{% for os_id, os in operating_systems.items() %} - {{ os.description }}
|
||||||
|
{% endfor %}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -7,14 +7,20 @@
|
|||||||
<h1>SUPPORT</h1>
|
<h1>SUPPORT</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="row half-margin">
|
<div class="row half-margin">
|
||||||
<a href="mailto:yolocolo@doesthisthing.work?subject=Please%20help!">yolocolo@doesthisthing.work</a>
|
<a href="mailto:support@cyberia.club?subject=Please%20help!">support@cyberia.club</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block subcontent %}
|
{% block subcontent %}
|
||||||
<p>
|
<p>
|
||||||
You can also find us on Matrix: <a
|
Note: We maintain a searchable archive of all support emails at
|
||||||
href="https://matrix.to/#/#untitled-hosting.public:autonomic.zone">#untitled-hosting.public:autonomic.zone</a>.
|
<a href="https://lists.cyberia.club/~cyberia/support">https://lists.cyberia.club/~cyberia/support</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you do not want your mail to appear in a public archive, email <a href="mailto:capsul@cyberia.club?subject=Please%20help!">capsul@cyberia.club</a> instead.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please describe your problem or feature request, and we will do our best to get back to you promptly. Thank you very much.
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<!-- Namecoin Address: N2aVL6pHtBp7EtNGb3jpsL2L2NyjBNbiB1 -->
|
<!-- Namecoin Address: N2aVL6pHtBp7EtNGb3jpsL2L2NyjBNbiB1 -->
|
||||||
<link href="{{ url_for('static', filename='favicon.yolocolo.ico') }}" rel="icon">
|
<link href="{{ url_for('static', filename='favicon.yolocolo.ico') }}" rel="icon">
|
||||||
<title>{% block title %}{% endblock %}{% if self.title() %} - {% endif %}Capsul</title>
|
<title>{% block title %}{% endblock %}{% if self.title() %} - {% endif %}Serverscoop</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
<meta name="Description" content="Cyberia Capsul">
|
<meta name="Description" content="Cyberia Capsul">
|
||||||
@ -14,7 +14,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<div class="row justify-space-between half-margin">
|
<div class="row justify-space-between half-margin">
|
||||||
<div>
|
<div>
|
||||||
🦉 <a href="/"><b>YOLOCOLO</b></a>
|
🍞 <a href="/"><b>serverscoop</b></a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
@ -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 & API Keys</a>
|
||||||
<a href="/console/account-balance">Account Balance</a>
|
<a href="/console/account-balance">Account Balance</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -49,9 +49,7 @@
|
|||||||
<footer>
|
<footer>
|
||||||
This server runs <a
|
This server runs <a
|
||||||
href="https://giit.cyberia.club/~forest/capsul-flask">capsul-flask</a> by
|
href="https://giit.cyberia.club/~forest/capsul-flask">capsul-flask</a> by
|
||||||
Cyberia Computer Club, available under the <a
|
Cyberia Computer Club, available under the GNU AFFERO GENERAL PUBLIC LICENSE.<br/><br/>
|
||||||
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>
|
<a href="https://git.autonomic.zone/3wordchant/capsul-flask/src/branch/yolocolo/capsulflask{% block pagesource %}{% endblock %}">View page source</a>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
@ -3,13 +3,11 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>
|
<h1>
|
||||||
<pre>
|
<pre>
|
||||||
_ _
|
___ ___ _ ____ _____ _ __ ___ ___ ___ ___ _ __
|
||||||
_ _ ___ | | ___ ___ ___ | | ___
|
/ __|/ _ \ '__\ \ / / _ \ '__/ __|/ __/ _ \ / _ \| '_ \
|
||||||
| | | |/ _ \| |/ _ \ / __/ _ \| |/ _ \
|
\__ \ __/ | \ V / __/ | \__ \ (_| (_) | (_) | |_) |
|
||||||
| |_| | (_) | | (_) | (_| (_) | | (_) |
|
|___/\___|_| \_/ \___|_| |___/\___\___/ \___/| .__/
|
||||||
\__, |\___/|_|\___/ \___\___/|_|\___/
|
|_|
|
||||||
|___/
|
|
||||||
|
|
||||||
</pre>
|
</pre>
|
||||||
<span>Co-operative hosting using <a href="https://cyberia.club">Cyberia</a>'s Capsul</span>
|
<span>Co-operative hosting using <a href="https://cyberia.club">Cyberia</a>'s Capsul</span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
Reference in New Issue
Block a user