diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index 352ab1b..85fe4bb 100644 --- a/capsulflask/__init__.py +++ b/capsulflask/__init__.py @@ -38,6 +38,10 @@ app.config.from_mapping( HUB_TOKEN=os.environ.get("HUB_TOKEN", default="default"), DATABASE_URL=os.environ.get("DATABASE_URL", default="sql://postgres:dev@localhost:5432/postgres"), + + # https://www.postgresql.org/docs/9.1/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS + DATABASE_SSLMODE=os.environ.get("DATABASE_SSLMODE", default="prefer"), + DATABASE_SCHEMA=os.environ.get("DATABASE_SCHEMA", default="public"), MAIL_SERVER=os.environ.get("MAIL_SERVER", default="m1.nullhex.com"), diff --git a/capsulflask/auth.py b/capsulflask/auth.py index 038b692..a98a286 100644 --- a/capsulflask/auth.py +++ b/capsulflask/auth.py @@ -43,17 +43,28 @@ def login(): errors.append("enter a valid email address") if len(errors) == 0: - token = get_model().login(email) + result = get_model().login(email) + token = result[0] + ignoreCaseMatches = result[1] if token is None: errors.append("too many logins. please use one of the existing login links that have been emailed to you") else: link = f"{current_app.config['BASE_URL']}/auth/magic/{token}" + message = (f"Navigate to {link} to log into Capsul.\n" + "\nIf you didn't request this, ignore this message.") + + if len(ignoreCaseMatches) > 0: + joinedMatches = " or ".join(map(lambda x: f"'{x}'", ignoreCaseMatches)) + message = (f"You tried to log in as '{email}', but that account doesn't exist yet. \n" + f"If you would like to create a new account for '{email}', click here {link} \n\n" + f"If you meant to log in as {joinedMatches}, please return to https://capsul.org \n" + "and log in again with the correct (case-sensitive) email address.") + current_app.config["FLASK_MAIL_INSTANCE"].send( Message( "Click This Link to Login to Capsul", - body=(f"Navigate to {link} to log into Capsul.\n" - "\nIf you didn't request this, ignore this message."), + body=message, recipients=[email] ) ) diff --git a/capsulflask/console.py b/capsulflask/console.py index 5630158..ef2f42a 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -1,5 +1,6 @@ import re import sys +import json from datetime import datetime, timedelta from flask import Blueprint from flask import flash @@ -26,19 +27,22 @@ def makeCapsulId(): lettersAndNumbers = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10) return f"capsul-{lettersAndNumbers}" -def double_check_capsul_address(id, ipv4): +def double_check_capsul_address(id, ipv4, get_ssh_host_keys): try: - result = current_app.config["HUB_MODEL"].get(id) + result = current_app.config["HUB_MODEL"].get(id, get_ssh_host_keys) if result.ipv4 != ipv4: ipv4 = result.ipv4 get_model().update_vm_ip(email=session["account"], id=id, ipv4=result.ipv4) + if get_ssh_host_keys: + get_model().update_vm_ssh_host_keys(email=session["account"], id=id, ssh_host_keys=result.ssh_host_keys) except: current_app.logger.error(f""" the virtualization model threw an error in double_check_capsul_address of {id}: {my_exec_info_message(sys.exc_info())}""" ) + return None - return ipv4 + return result @bp.route("/") @account_required @@ -54,7 +58,9 @@ def index(): # for now we are going to check the IP according to the virt model # on every request. this could be done by a background job and cached later on... for vm in vms: - vm["ipv4"] = double_check_capsul_address(vm["id"], vm["ipv4"]) + result = double_check_capsul_address(vm["id"], vm["ipv4"], False) + if result is not None: + vm["ipv4"] = result.ipv4 vms = list(map( lambda x: dict( @@ -105,9 +111,17 @@ def detail(id): return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True) else: - vm["ipv4"] = double_check_capsul_address(vm["id"], vm["ipv4"]) + 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) + + if vm_from_virt_model is not None: + vm["ipv4"] = vm_from_virt_model.ipv4 + if needs_ssh_host_keys: + vm["ssh_host_keys"] = vm_from_virt_model.ssh_host_keys + vm["created"] = vm['created'].strftime("%b %d %Y %H:%M") - vm["ssh_public_keys"] = ", ".join(vm["ssh_public_keys"]) if len(vm["ssh_public_keys"]) > 0 else "" + vm["ssh_authorized_keys"] = ", ".join(vm["ssh_authorized_keys"]) if len(vm["ssh_authorized_keys"]) > 0 else "" return render_template( "capsul-detail.html", @@ -124,7 +138,7 @@ def detail(id): def create(): vm_sizes = get_model().vm_sizes_dict() operating_systems = get_model().operating_systems_dict() - ssh_public_keys = get_model().list_ssh_public_keys_for_account(session["account"]) + public_keys_for_account = get_model().list_ssh_public_keys_for_account(session["account"]) account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow()) capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024) errors = list() @@ -155,7 +169,7 @@ def create(): if f"ssh_key_{i}" in request.form: posted_name = request.form[f"ssh_key_{i}"] key = None - for x in ssh_public_keys: + for x in public_keys_for_account: if x['name'] == posted_name: key = x if key: @@ -180,7 +194,7 @@ def create(): id=id, size=size, os=os, - ssh_public_keys=list(map(lambda x: x["name"], posted_keys)) + ssh_authorized_keys=list(map(lambda x: x["name"], posted_keys)) ) current_app.config["HUB_MODEL"].create( email = session["account"], @@ -188,14 +202,17 @@ def create(): template_image_file_name=operating_systems[os]['template_image_file_name'], vcpus=vm_sizes[size]['vcpus'], memory_mb=vm_sizes[size]['memory_mb'], - ssh_public_keys=list(map(lambda x: x["content"], posted_keys)) + ssh_authorized_keys=list(map(lambda x: x["content"], posted_keys)) ) return redirect(f"{url_for('console.index')}?created={id}") affordable_vm_sizes = dict() for key, vm_size in vm_sizes.items(): - if vm_size["dollars_per_month"] <= account_balance: + # 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 for error in errors: @@ -209,9 +226,9 @@ def create(): csrf_token = session["csrf-token"], capacity_avaliable=capacity_avaliable, account_balance=format(account_balance, '.2f'), - ssh_public_keys=ssh_public_keys, - ssh_public_key_count=len(ssh_public_keys), - no_ssh_public_keys=len(ssh_public_keys) == 0, + ssh_public_keys=public_keys_for_account, + ssh_public_key_count=len(public_keys_for_account), + no_ssh_public_keys=len(public_keys_for_account) == 0, operating_systems=operating_systems, cant_afford=len(affordable_vm_sizes) == 0, vm_sizes=affordable_vm_sizes diff --git a/capsulflask/db.py b/capsulflask/db.py index 09ad5c8..a75663f 100644 --- a/capsulflask/db.py +++ b/capsulflask/db.py @@ -9,6 +9,7 @@ from flask import current_app from flask import g from capsulflask.db_model import DBModel +from capsulflask.shared import my_exec_info_message def init_app(app): databaseUrl = urlparse(app.config['DATABASE_URL']) @@ -20,7 +21,8 @@ def init_app(app): password = databaseUrl.password, host = databaseUrl.hostname, port = databaseUrl.port, - database = databaseUrl.path[1:] + database = databaseUrl.path[1:], + sslmode = app.config['DATABASE_SSLMODE'] ) schemaMigrations = {} @@ -40,7 +42,7 @@ def init_app(app): hasSchemaVersionTable = False actionWasTaken = False schemaVersion = 0 - desiredSchemaVersion = 9 + desiredSchemaVersion = 13 cursor = connection.cursor() @@ -126,4 +128,3 @@ def close_db(e=None): db_model.cursor.close() current_app.config['PSYCOPG2_CONNECTION_POOL'].putconn(db_model.connection) - diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 6bad609..46101da 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -21,18 +21,26 @@ class DBModel: def login(self, email): self.cursor.execute("SELECT * FROM accounts WHERE email = %s", (email, )) - if len(self.cursor.fetchall()) == 0: - self.cursor.execute("INSERT INTO accounts (email) VALUES (%s)", (email, )) + hasExactMatch = len(self.cursor.fetchall()) + self.cursor.execute("SELECT * FROM accounts WHERE email = %s AND ever_logged_in = TRUE", (email, )) + everLoggedIn = len(self.cursor.fetchall()) + ignoreCaseMatches = [] + if everLoggedIn == 0: + self.cursor.execute("SELECT email FROM accounts WHERE lower_case_email = %s AND email != %s", (email.lower(), email)) + ignoreCaseMatches = list(map(lambda x: x[0], self.cursor.fetchall())) + + if hasExactMatch == 0: + self.cursor.execute("INSERT INTO accounts (email, lower_case_email) VALUES (%s, %s)", (email, email.lower())) - self.cursor.execute("SELECT token FROM login_tokens WHERE email = %s", (email, )) + self.cursor.execute("SELECT token FROM login_tokens WHERE email = %s and created > (NOW() - INTERVAL '20 min')", (email, )) if len(self.cursor.fetchall()) > 2: - return None + return (None, ignoreCaseMatches) token = generate() self.cursor.execute("INSERT INTO login_tokens (email, token) VALUES (%s, %s)", (email, token)) self.connection.commit() - return token + return (token, ignoreCaseMatches) def consume_token(self, token): self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s and created > (NOW() - INTERVAL '20 min')", (token, )) @@ -40,6 +48,7 @@ class DBModel: if row: email = row[0] self.cursor.execute("DELETE FROM login_tokens WHERE email = %s", (email, )) + self.cursor.execute("UPDATE accounts SET ever_logged_in = TRUE WHERE email = %s", (email, )) self.connection.commit() return email return None @@ -110,7 +119,17 @@ class DBModel: self.cursor.execute("UPDATE vms SET last_seen_ipv4 = %s WHERE email = %s AND id = %s", (ipv4, email, id)) self.connection.commit() - def create_vm(self, email, id, size, os, ssh_public_keys): + def update_vm_ssh_host_keys(self, email, id, ssh_host_keys): + for key in ssh_host_keys: + self.cursor.execute(""" + INSERT INTO vm_ssh_host_key (email, vm_id, key_type, content, sha256) + VALUES (%s, %s, %s, %s, %s) + """, + (email, id, key['key_type'], key['content'], key['sha256']) + ) + self.connection.commit() + + def create_vm(self, email, id, size, os, ssh_authorized_keys): self.cursor.execute(""" INSERT INTO vms (email, id, size, os) VALUES (%s, %s, %s, %s) @@ -118,12 +137,12 @@ class DBModel: (email, id, size, os) ) - for ssh_public_key in ssh_public_keys: + for ssh_authorized_key in ssh_authorized_keys: self.cursor.execute(""" - INSERT INTO vm_ssh_public_key (email, vm_id, ssh_public_key_name) + INSERT INTO vm_ssh_authorized_key (email, vm_id, ssh_public_key_name) VALUES (%s, %s, %s) """, - (email, id, ssh_public_key) + (email, id, ssh_authorized_key) ) self.connection.commit() @@ -151,11 +170,19 @@ class DBModel: ) self.cursor.execute(""" - SELECT ssh_public_key_name FROM vm_ssh_public_key - WHERE vm_ssh_public_key.email = %s AND vm_ssh_public_key.vm_id = %s""", + SELECT ssh_public_key_name FROM vm_ssh_authorized_key + WHERE vm_ssh_authorized_key.email = %s AND vm_ssh_authorized_key.vm_id = %s""", (email, id) ) - vm["ssh_public_keys"] = list(map( lambda x: x[0], self.cursor.fetchall() )) + vm["ssh_authorized_keys"] = list(map( lambda x: x[0], self.cursor.fetchall() )) + + + self.cursor.execute(""" + SELECT key_type, content, sha256 FROM vm_ssh_host_key + WHERE vm_ssh_host_key.email = %s AND vm_ssh_host_key.vm_id = %s""", + (email, id) + ) + vm["ssh_host_keys"] = list(map( lambda x: dict(key_type=x[0], content=x[1], sha256=x[2]), self.cursor.fetchall() )) return vm @@ -247,7 +274,7 @@ class DBModel: if row: self.cursor.execute( "DELETE FROM unresolved_btcpay_invoices WHERE id = %s", (id,) ) if not completed: - self.cursor.execute("UPDATE payments SET invalidated = True WHERE email = %s id = %s", (row[0], row[1])) + self.cursor.execute("UPDATE payments SET invalidated = TRUE WHERE email = %s id = %s", (row[0], row[1])) self.connection.commit() @@ -268,7 +295,7 @@ class DBModel: self.connection.commit() def all_accounts(self): - self.cursor.execute("SELECT email, account_balance_warning FROM accounts") + self.cursor.execute("SELECT email, account_balance_warning FROM accounts WHERE ever_logged_in = TRUE ") return list(map(lambda row: dict(email=row[0], account_balance_warning=row[1]), self.cursor.fetchall())) diff --git a/capsulflask/landing.py b/capsulflask/landing.py index 0724225..3c1781e 100644 --- a/capsulflask/landing.py +++ b/capsulflask/landing.py @@ -22,6 +22,10 @@ def pricing(): def faq(): return render_template("faq.html") +@bp.route("/about-ssh") +def about_ssh(): + return render_template("about-ssh.html") + @bp.route("/changelog") def changelog(): return render_template("changelog.html") diff --git a/capsulflask/schema_migrations/09_down_email_case.sql b/capsulflask/schema_migrations/09_down_email_case.sql new file mode 100644 index 0000000..050ecdf --- /dev/null +++ b/capsulflask/schema_migrations/09_down_email_case.sql @@ -0,0 +1,5 @@ + +ALTER TABLE accounts DROP COLUMN lower_case_email; +ALTER TABLE accounts DROP COLUMN ever_logged_in; + +UPDATE schemaversion SET version = 8; \ No newline at end of file diff --git a/capsulflask/schema_migrations/09_up_email_case.sql b/capsulflask/schema_migrations/09_up_email_case.sql new file mode 100644 index 0000000..01b3e71 --- /dev/null +++ b/capsulflask/schema_migrations/09_up_email_case.sql @@ -0,0 +1,10 @@ +ALTER TABLE accounts +ADD COLUMN lower_case_email TEXT NULL; + +ALTER TABLE accounts +ADD COLUMN ever_logged_in BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE accounts set lower_case_email = LOWER(accounts.email); +UPDATE accounts set ever_logged_in = TRUE; + +UPDATE schemaversion SET version = 9; diff --git a/capsulflask/schema_migrations/10_down_guixsystem1.2.0.sql b/capsulflask/schema_migrations/10_down_guixsystem1.2.0.sql new file mode 100644 index 0000000..04bec59 --- /dev/null +++ b/capsulflask/schema_migrations/10_down_guixsystem1.2.0.sql @@ -0,0 +1,3 @@ +DELETE FROM os_images WHERE id = 'guixsystem120'; + +UPDATE schemaversion SET version = 9; diff --git a/capsulflask/schema_migrations/10_up_guixsystem1.2.0.sql b/capsulflask/schema_migrations/10_up_guixsystem1.2.0.sql new file mode 100644 index 0000000..76be19a --- /dev/null +++ b/capsulflask/schema_migrations/10_up_guixsystem1.2.0.sql @@ -0,0 +1,4 @@ +INSERT INTO os_images (id, template_image_file_name, description, deprecated) +VALUES ('guixsystem120', 'guixsystem/1.2.0/root.img.qcow2', 'Guix System 1.2.0', FALSE); + +UPDATE schemaversion SET version = 10; diff --git a/capsulflask/schema_migrations/11_down_alpine_3.13.sql b/capsulflask/schema_migrations/11_down_alpine_3.13.sql new file mode 100644 index 0000000..5b4a883 --- /dev/null +++ b/capsulflask/schema_migrations/11_down_alpine_3.13.sql @@ -0,0 +1,5 @@ +DELETE FROM os_images WHERE id = 'alpine313'; + +UPDATE os_images SET deprecated = FALSE WHERE id = 'alpine312'; + +UPDATE schemaversion SET version = 10; diff --git a/capsulflask/schema_migrations/11_up_alpine_3.13.sql b/capsulflask/schema_migrations/11_up_alpine_3.13.sql new file mode 100644 index 0000000..d46e63a --- /dev/null +++ b/capsulflask/schema_migrations/11_up_alpine_3.13.sql @@ -0,0 +1,6 @@ +INSERT INTO os_images (id, template_image_file_name, description, deprecated) +VALUES ('alpine313', 'alpine/3.13/root.img.qcow2', 'Alpine Linux 3.13', FALSE); + +UPDATE os_images SET deprecated = TRUE WHERE id = 'alpine312'; + +UPDATE schemaversion SET version = 11; diff --git a/capsulflask/schema_migrations/12_down_ssh_host_keys.sql b/capsulflask/schema_migrations/12_down_ssh_host_keys.sql new file mode 100644 index 0000000..054222e --- /dev/null +++ b/capsulflask/schema_migrations/12_down_ssh_host_keys.sql @@ -0,0 +1,7 @@ + + +DROP TABLE vm_ssh_host_key; + +ALTER TABLE vm_ssh_authorized_key RENAME TO vm_ssh_public_key; + +UPDATE schemaversion SET version = 11; \ No newline at end of file diff --git a/capsulflask/schema_migrations/12_up_ssh_host_keys.sql b/capsulflask/schema_migrations/12_up_ssh_host_keys.sql new file mode 100644 index 0000000..ae03c1e --- /dev/null +++ b/capsulflask/schema_migrations/12_up_ssh_host_keys.sql @@ -0,0 +1,14 @@ + +CREATE TABLE vm_ssh_host_key ( + email TEXT NOT NULL, + vm_id TEXT NOT NULL, + key_type TEXT NOT NULL, + content TEXT NOT NULL, + sha256 TEXT NOT NULL, + FOREIGN KEY (email, vm_id) REFERENCES vms(email, id) ON DELETE CASCADE, + PRIMARY KEY (email, vm_id, key_type) +); + +ALTER TABLE vm_ssh_public_key RENAME TO vm_ssh_authorized_key; + +UPDATE schemaversion SET version = 12; \ No newline at end of file diff --git a/capsulflask/schema_migrations/13_down_introduce_hosts.sql b/capsulflask/schema_migrations/13_down_introduce_hosts.sql new file mode 100644 index 0000000..da64115 --- /dev/null +++ b/capsulflask/schema_migrations/13_down_introduce_hosts.sql @@ -0,0 +1,11 @@ + +DROP TABLE host_operation; + +DROP TABLE operations; + +ALTER TABLE vms DROP COLUMN host; + +DROP TABLE hosts; + + +UPDATE schemaversion SET version = 12; \ No newline at end of file diff --git a/capsulflask/schema_migrations/13_up_introduce_hosts.sql b/capsulflask/schema_migrations/13_up_introduce_hosts.sql new file mode 100644 index 0000000..ecfe46e --- /dev/null +++ b/capsulflask/schema_migrations/13_up_introduce_hosts.sql @@ -0,0 +1,32 @@ + + +CREATE TABLE hosts ( + id TEXT PRIMARY KEY NOT NULL, + last_health_check TIMESTAMP NOT NULL DEFAULT NOW(), + https_url TEXT NOT NULL, + token TEXT NOT NULL +); + +INSERT INTO hosts (id, https_url, token) VALUES ('baikal', 'http://localhost:5000', 'changeme'); + +ALTER TABLE vms +ADD COLUMN host TEXT REFERENCES hosts(id) ON DELETE RESTRICT DEFAULT 'baikal'; + +CREATE TABLE operations ( + id SERIAL PRIMARY KEY , + email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, + created TIMESTAMP NOT NULL DEFAULT NOW(), + payload TEXT NOT NULL +); + +CREATE TABLE host_operation ( + host TEXT NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT, + operation INTEGER NOT NULL REFERENCES operations(id) ON DELETE RESTRICT, + assignment_status TEXT NULL, + assigned TIMESTAMP NULL, + completed TIMESTAMP NULL, + results TEXT NULL, + PRIMARY KEY (host, operation) +); + +UPDATE schemaversion SET version = 13; diff --git a/capsulflask/shared.py b/capsulflask/shared.py index 867bb95..92f9046 100644 --- a/capsulflask/shared.py +++ b/capsulflask/shared.py @@ -1,18 +1,27 @@ import re from flask import current_app +from typing import List class OnlineHost: def __init__(self, id: str, url: str): self.id = id self.url = url +# I decided to just use dict everywhere instead because I have to use dict to read it from json +# class SSHHostKey: +# def __init__(self, key_type=None, content=None, sha256=None): +# self.key_type = key_type +# self.content = content +# self.sha256 = sha256 + class VirtualMachine: - def __init__(self, id, host, ipv4=None, ipv6=None): + def __init__(self, id, host, ipv4=None, ipv6=None, ssh_host_keys: List[dict] = list()): self.id = id self.host = host self.ipv4 = ipv4 self.ipv6 = ipv6 + self.ssh_host_keys = ssh_host_keys class VirtualizationInterface: def capacity_avaliable(self, additional_ram_bytes: int) -> bool: diff --git a/capsulflask/shell_scripts/ssh-keyscan.sh b/capsulflask/shell_scripts/ssh-keyscan.sh new file mode 100755 index 0000000..909ac40 --- /dev/null +++ b/capsulflask/shell_scripts/ssh-keyscan.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +ip_address="$1" + +if echo "$ip_address" | grep -vqE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then + echo "ip_address $ip_address must match "'"^([0-9]{1,3}\.){3}[0-9]{1,3}$"' + exit 1 +fi + +printf '[' +DELIMITER="" +ssh-keyscan "$ip_address" 2>/dev/null | while read -r line; do + if echo "$line" | grep -qE "^$ip_address"' +(ssh|ecdsa)-[0-9A-Za-z+/_=@. -]+$'; then + KEY_CONTENT="$(echo "$line" | awk '{ print $2 " " $3 }')" + FINGERPRINT_OUTPUT="$(echo "$KEY_CONTENT" | ssh-keygen -l -E sha256 -f - | sed -E 's/^[0-9]+ SHA256:([0-9A-Za-z+/-]+) .+ \(([A-Z0-9]+)\)$/\1 \2/g')" + SHA256_HASH="$(echo "$FINGERPRINT_OUTPUT" | awk '{ print $1 }')" + KEY_TYPE="$(echo "$FINGERPRINT_OUTPUT" | awk '{ print $2 }')" + printf '%s\n {"key_type":"%s", "content":"%s", "sha256":"%s"}' "$DELIMITER" "$KEY_TYPE" "$KEY_CONTENT" "$SHA256_HASH" + DELIMITER="," + fi +done +printf '\n]\n' + diff --git a/capsulflask/static/style.css b/capsulflask/static/style.css index 80ffae3..0d01de0 100644 --- a/capsulflask/static/style.css +++ b/capsulflask/static/style.css @@ -211,10 +211,22 @@ input[type=image].submit { } - - -ul li { +ul li, ol li { margin: 0.5em 0; + margin-left: 1.2rem; +} + +.long-form p, .long-form li { + line-height: 2em; +} + +.long-form p .code, .long-form li .code{ + line-height: 1em; + padding: 5px; + padding-top: 3px; + margin-top: 2px; + padding-bottom: 4px; + border-radius: 4px; } hr { @@ -277,8 +289,12 @@ div.metric { border: 1px solid #777e73; background: #bdc7b810; } -.break-word { - word-break: break-word; +pre.code.wrap { + white-space: pre-wrap; +} + +.break-all { + word-break: break-all; } .dim { diff --git a/capsulflask/templates/about-ssh.html b/capsulflask/templates/about-ssh.html new file mode 100644 index 0000000..7247c3a --- /dev/null +++ b/capsulflask/templates/about-ssh.html @@ -0,0 +1,347 @@ +{% extends 'base.html' %} + +{% block title %}About SSH{% endblock %} + +{% block content %} +

Understanding the Secure Shell Protocol (SSH)

+{% endblock %} + +{% block subcontent %} +
+

+ In order to use our service, you will have to use the Secure Shell protocol (SSH) to connect to your capsul. +

+

+ SSH is a very old tool, created back when the internet was a different place, with different use cases and concerns. + In many ways, the protocol has failed to evolve to meet the needs of our 21st century global internet. + Instead, the users of SSH (tech heads, sysadmins, etc) have had to evolve our processes to work around SSH's limitations. +

+

+ These days, we use SSH + public-key cryptography to establish secure connections to our servers. + If you are not familiar with the concept of public key cryptography, cryptographic signatures, + or diffie-hellman key exchange, you may wish to see + the wikipedia article for a refresher. +

+ +

Public Key Crypto and Key Exchange: The TL;DR

+ +

+ Computers can generate "key pairs" which consist of a public key and a private key. Given a public key pair A: +

+
    +
  1. + A computer which has access to public key A can encrypt data, + and then ONLY a computer which has access private key A can decrypt & read it +
  2. +
  3. + Likewise, a computer which has access to private key A can encrypt data, + and any a computer which has access public key A can decrypt it, + thus PROVING the message must have come from someone who posesses private key A +
  4. +
+

+ Key exchange is a process in which two computers, Computer A and Computer B (often referred to as Alice and Bob) + both create key pairs, so you have key pair A and key pair B, for a total of 4 keys: +

+
    +
  1. public key A
  2. +
  3. private key A
  4. +
  5. public key B
  6. +
  7. private key B
  8. +
+

+ In simplified terms, during a key exchange, +

+
    +
  1. computer A sends computer B its public key
  2. +
  3. computer B sends computer A its public key
  4. +
  5. computer A sends computer B + a message which is encrypted with computer B's public key
  6. +
  7. computer B sends computer A + a message which is encrypted with computer A's public key
  8. +
+

+ The way this process is carried out allows A and B to communicate with each-other securely, which is great,

+ + HOWEVER, there is a catch!! +

+ +

+ When computers A and B are trying to establish a secure connection for the first time, + we assume that the way they communicate right now is NOT secure. That means that someone on the network + between A and B can read & modify + all messages they send to each-other! You might be able to see where this is heading... +

+

+ When computer A sends its public key to computer B, + someone in the middle (lets call it computer E, or Eve) could record that message, save it, + and then replace it with a forged message to computer B containing public key E + (from a key pair that computer E generated). + + If this happens, when computer B sends an encrypted message to computer A, + B thinks that A's public key is actually public key E, so it will use public key E to encrypt. + And again, computer E in the middle can intercept the message, and they can decrypt it as well + because they have private key E. + Finally, they can relay the same message to computer A, this time encrypted with computer A's public key. + This is called a Man In The Middle (MITM) attack. +

+

+ Without some additional verification method, + Computer A AND Computer B can both be duped and the connection is NOT really secure. +

+ +

Authenticating Public Keys: A Tale of Two Protocols

+ +

+ Now that we have seen how key exhange works, + and we understand that in order to prevent MITM attacks, all participants have to have a way of knowing + whether a given public key is authentic or not, I can explain what I meant when I said +

+

+ > [SSH] has failed to evolve to meet the needs of our 21st century global internet +

+

+ In order to explain this, let's first look at how a different, more modern protocol, + Transport Layer Security (or TLS) solved this problem. + TLS, (still sometimes called by its olde name "Secure Sockets Layer", or SSL) was created to enable HTTPS, to allow + internet users to log into web sites securely and purchase things online by entering their credit card number. + Of course, this required security that actually works; if someone could MITM attack the connection, they could easily + steal tons of credit card numbers and passwords. +

+

+ In order to enable this, a new standard called X.509 was created. + X.509 dictates the data format of certificates and keys (public keys and private keys), and it also defines + a simple and easy way to determine whether a given certificate (public key) is authentic. + X.509 introduced the concept of a Certificate Authority, or CA. + These CAs were supposed to be bank-like public institutions of power which everyone could trust. + The CA would create a key pair on an extremely secure computer, and then a CA Certificate (the public side of that key pair) + would be distributed along with every copy of Windows, Mac OS, and Linux. Then folks who wanted to run a secure web server + could generate thier OWN key pair for thier web server, + and pay the CA to sign thier web server's X.509 certificate (public key) with the highly protected CA private key. + Critically, issue date, expiration date, and the domain name of the web server, like foo.example.com, would have to be included + in the x.509 certiciate along with the public key. + This way, when the user types https://foo.example.com into thier web browser: +

+
    +
  1. The web browser sends a TLS ClientHello request to the server
  2. +
  3. + The server responds with a ServerHello & ServerCertificate message +
      +
    • The ServerCertificate message contains the X.509 certificate for the web server at foo.example.com
    • +
    +
  4. +
  5. The web browser inspects the X.509 certificate +
      +
    • + Is the current date in between the issued date and expiry date of the certificate? + If not, display an EXPIRED_CERTIFICATE error. +
    • +
    • + Does the domain name the user typed in, foo.example.com, match the domain name in the certificate? + If not, display a BAD_CERT_DOMAIN error. +
    • +
    • + Does the certificate contain a valid CA signature? + (can the signature on the certificate be decrypted by one of the CA Certificates included with the operating system?) + If not, display an UNKNOWN_ISSUER error. +
    • +
    +
  6. +
  7. Assuming all the checks pass, the web browser trusts the certificate and connects
  8. +
+

+ This system enabled the internet to grow and flourish: + purchasing from a CA was the only way to get a valid X.509 certificate for a website, + and guaranteeing authenticity was in the CA's business interest. + The CAs kept their private keys behind razor wire and armed guards, and followed strict rules to ensure that only the right + people got thier certificates signed. + Only the CAs themselves or anyone who had enough power to force them to create a fraudulent certificate + would be able to execute MITM attacks. +

+

+ The TLS+X.509 Certificate Authority works well for HTTP and other application protocols, because +

+
    +
  • Most internet users don't have the patience to manually verify the authenticity of digital certificates.
  • +
  • Most internet users don't understand or care how it works; they just want to connect right now.
  • +
  • Businesses and organizations that run websites are generally willing to jump through hoops and + subjugate themselves to authorities in order to offer a more secure application experience to thier users.
  • +
  • The centralization & problematic power dynamic which CAs represent + is easily swept under the rug, if it doesn't directly or noticably impact the average person, who cares?
  • +
+ +

+ However, this would never fly with SSH. You have to understand, SSH does not come from Microsoft, it does not come from Apple, + in fact, it does not even come from Linux or GNU. SSH comes from BSD. + Berkeley Software Distribution. Most people don't even know + what BSD is. It's Deep Nerdcore material. The people who maintain SSH are not playing around, they would never + allow themselves to be subjugated by so-called "Certificate Authorities". + So, what are they doing instead? Where is SSH at? Well, back when it was created, computer security was easy — + a very minimal defense was enough to deter attackers. + In order to help prevent these MITM attacks, instead of something like X.509, SSH employs a policy called + Trust On First Use (TOFU). +

+ +

+ The SSH client application keeps a record of every server it has ever connected to + in a file ~/.ssh/known_hosts. +

+ +

+ (the tilde ~ here represents the user's home directory, + /home/username on linux, + C:\Users\username on Windows, and + /Users/username on MacOS). +

+ +

+ If the user asks the SSH client to connect to a server it has never seen before, + it will print a prompt like this to the terminal: +

+ +
The authenticity of host 'fooserver.com (69.4.20.69)' can't be established.
+    ECDSA key fingerprint is SHA256:EXAMPLE1xY4JUVhYirOVlfuDFtgTbaiw3x29xYizEeU.
+    Are you sure you want to continue connecting (yes/no/[fingerprint])?
+ +

+ Here, the SSH client is displaying the fingerprint (SHA256 hash) + of the public key provided by the server at fooserver.com. + Back in the day, when SSH was created, servers lived for months to years, not minutes, and they were installed by hand. + So it would have been perfectly reasonable to call the person installing the server on thier + Nokia 909 + and ask them to log into it & read off the host key fingerprint over the phone. + After verifing that the fingerprints match in the phone call, the user would type yes + to continue. +

+ +

+ After the SSH client connects to a server for the first time, it will record the server's IP address and public key in the + ~/.ssh/known_hosts file. All subsequent connections will simply check the public key + the server presents against the public key it has recorded in the ~/.ssh/known_hosts file. + If the two public keys match, the connection will continue without prompting the user, however, if they don't match, + the SSH client will display a scary warning message: +

+
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@       WARNING: POSSIBLE DNS SPOOFING DETECTED!          @
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+The ECDSA host key for fooserver.com has changed,
+and the key for the corresponding IP address 69.4.20.42
+is unknown. This could either mean that
+DNS SPOOFING is happening or the IP address for the host
+and its host key have changed at the same time.
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
+Someone could be eavesdropping on you right now (man-in-the-middle attack)!
+It is also possible that a host key has just been changed.
+The fingerprint for the ECDSA key sent by the remote host is
+SHA256:EXAMPLEpDDefcNcIROtFpuTiHC1j3iNU74aaKFO03+0.
+Please contact your system administrator.
+Add correct host key in /root/.ssh/known_hosts to get rid of this message.
+Offending ECDSA key in /root/.ssh/known_hosts:1
+  remove with:
+  ssh-keygen -f "/root/.ssh/known_hosts" -R "fooserver.com"
+ECDSA host key for fooserver.com has changed and you have requested strict checking.
+Host key verification failed.
+
+ +

+ This is why it's called Trust On First Use: + + SSH protocol assumes that when you type yes in response to the prompt during your first connection, + you really did verify that the server's public key fingerprint matches. + + If you type yes here without checking the server's host key somehow, you could add an attackers public key to the trusted + list in your ~/.ssh/known_hosts file; if you type yes blindly, you are + completely disabling all security of the SSH connection. + It can be fully man-in-the-middle attacked & you are + vulnerable to surveillance, command injection, even emulation/falsification of the entire stream. + Will anyone actually attack you like that? Who knows. Personally, I'd rather not find out. +

+ +

+ So what are technologists to do? Most cloud providers don't "provide" an easy way to get the SSH host public keys + for instances that users create on thier platform. For example, see this + + question posted by a frustrated user trying to secure thier connection to a digitalocean droplet. + + Besides using the provider's HTTPS-based console to log into the machine & directly read the public key, + providers also recommend using a "userdata script". + This script would run on boot & upload the machine's SSH public keys to a + trusted location like Backblaze B2 or + Amazon S3[1], for an application to retrieve later. + As an example, I wrote a + + userdata script which does this + for my own cloud compute management tool called + rootsystem. + Later in the process, rootsystem will + + download the public keys from the Object Storage provider + and add them to the ~/.ssh/known_hosts file + before finally + + invoking the ssh client against the cloud host. +

+ +

+ Personally, I think it's disgusting and irresponsible to require users to go through that much work + just to be able to connect to their instance securely. However, this practice appears to be an industry standard. + It's gross, but it's where we're at right now. +

+ +

+ So for capsul, we obviously wanted to do better. + We wanted to make this kind of thing as easy as possible for the user, + so I'm proud to announce as of today, capsul SSH host key fingerprints will be displayed on the capsul detail page, + as well as the host's SSH public keys themselves in ~/.ssh/known_hosts format. + Users can simply copy and paste these keys into thier ~/.ssh/known_hosts file and connect + with confidence that they are not being MITM attacked. +

+ +

Why ssh more ssh

+ +

+ SSH is a relatively low-level protocol, it should be kept simple and it should not depend on anything external. + It has to be this way, because often times SSH is the first service that runs on a server, before any other + services or processes launch. SSH server has to run no matter what, because it's what we're gonna depend on to + log in there and fix everything else which is broken! Also, SSH has to work for all computers, not just the ones which + have internet access or are reachable publically. + So, arguing that SSH should be wrapped in TLS or that SSH should use x.509 doesn't make much sense. +

+
+

+ > ssh didn’t needed an upgrade. SSH is perfect +

+
+

+ Because of the case for absolute simplicity, I think that in a cloud based use-case + it might even make sense to remove the TOFU and make the ssh client even less user friendly; requiring the + expected host key to be passed in on every command by default + would dramatically increase the security of real-world SSH usage. + In order to make it more human-friendly again while keeping the security benefits, + we can create a new layer of abstraction on top of SSH, create regime-specific automation & wrapper scripts. +

+

+ For example, when we build a JSON API for capsul, we could also provide a capsul-cli + application which contains an SSH wrapper that knows how to automatically grab & inject the authentic host keys and invoke ssh + in a single command. +

+ +

+ Cheers and best wishes,
+         Forest +

+ +
+

+ [1] fuck amazon +

+ +
+{% endblock %} + +{% block pagesource %}/templates/about-ssh.html{% endblock %} + diff --git a/capsulflask/templates/capsul-detail.html b/capsulflask/templates/capsul-detail.html index 8636ef2..91a87b6 100644 --- a/capsulflask/templates/capsul-detail.html +++ b/capsulflask/templates/capsul-detail.html @@ -73,8 +73,8 @@ cyberian
- - {{ vm['ssh_public_keys'] }} + + {{ vm['ssh_authorized_keys'] }}
@@ -85,6 +85,17 @@
+
+

ssh host key fingerprints

+
+ +
+
{% for key in vm['ssh_host_keys'] %}
+SHA256:{{ key.sha256 }} ({{ key.key_type }}){% endfor %}
+
+

@@ -136,6 +147,20 @@ +
+
+
+
+ add the following to your ~/.ssh/known_hosts file (optional) +
+
+
{% for key in vm['ssh_host_keys'] %}
+{{ vm['ipv4'] }} {{ key.content }}{% endfor %}
+
+
+ {% endif %} {% endblock %} diff --git a/capsulflask/templates/changelog.html b/capsulflask/templates/changelog.html index 6615055..885cdf6 100644 --- a/capsulflask/templates/changelog.html +++ b/capsulflask/templates/changelog.html @@ -8,7 +8,9 @@ {% block subcontent %}