feat: abstracting cloud providers

This commit is contained in:
Iain Learmonth 2023-02-26 12:52:08 +00:00
parent af36a545a1
commit 0a72aeed96
18 changed files with 629 additions and 181 deletions

View file

@ -50,7 +50,7 @@ class BridgeConf(AbstractConfiguration):
class Bridge(AbstractResource):
conf_id = db.Column(db.Integer, db.ForeignKey("bridge_conf.id"), nullable=False)
provider = db.Column(db.String(), nullable=False)
cloud_account_id = db.Column(db.Integer, db.ForeignKey("cloud_account.id"))
terraform_updated = db.Column(db.DateTime(), nullable=True)
nickname = db.Column(db.String(255), nullable=True)
fingerprint = db.Column(db.String(255), nullable=True)
@ -58,13 +58,14 @@ class Bridge(AbstractResource):
bridgeline = db.Column(db.String(255), nullable=True)
conf = db.relationship("BridgeConf", back_populates="bridges")
cloud_account = db.relationship("CloudAccount", back_populates="bridges")
@property
def brn(self) -> BRN:
return BRN(
group_id=0,
product="bridge",
provider=self.provider,
provider=self.cloud_account.provider.key,
resource_type="bridge",
resource_id=str(self.id)
)

View file

@ -0,0 +1,42 @@
import enum
from app.brm.brn import BRN
from app.extensions import db
from app.models import AbstractConfiguration
class CloudProvider(enum.Enum):
AWS = ("aws", "Amazon Web Services")
AZURE = ("azure", "Microsoft Azure")
BUNNY = ("bunny", "bunny.net")
CLOUDFLARE = ("cloudflare", "Cloudflare")
FASTLY = ("fastly", "Fastly")
HTTP = ("http", "HTTP")
GANDI = ("gandi", "Gandi")
GITHUB = ("github", "GitHub")
GITLAB = ("gitlab", "GitLab")
HCLOUD = ("hcloud", "Hetzner Cloud")
MAXMIND = ("maxmind", "MaxMind")
OVH = ("ovh", "OVH")
RFC2136 = ("rfc2136", "RFC2136 DNS Server")
def __init__(self, key: str, description: str):
self.key = key
self.description = description
class CloudAccount(AbstractConfiguration):
provider = db.Column(db.Enum(CloudProvider))
credentials = db.Column(db.JSON())
enabled = db.Column(db.Boolean())
# CDN Quotas
max_distributions = db.Column(db.Integer())
max_sub_distributions = db.Column(db.Integer())
# Compute Quotas
max_instances = db.Column(db.Integer())
bridges = db.relationship("Bridge", back_populates="cloud_account")
@property
def brn(self) -> BRN:
raise NotImplementedError("No BRN for cloud accounts")

View file

@ -14,6 +14,7 @@ from app.models.bridges import Bridge
from app.models.mirrors import Origin, Proxy
from app.models.base import Group
from app.models.onions import Eotk
from app.portal.cloud import bp as cloud
from app.portal.automation import bp as automation
from app.portal.bridgeconf import bp as bridgeconf
from app.portal.bridge import bp as bridge
@ -32,6 +33,7 @@ portal = Blueprint("portal", __name__, template_folder="templates", static_folde
portal.register_blueprint(automation, url_prefix="/automation")
portal.register_blueprint(bridgeconf, url_prefix="/bridgeconf")
portal.register_blueprint(bridge, url_prefix="/bridge")
portal.register_blueprint(cloud, url_prefix="/cloud")
portal.register_blueprint(eotk, url_prefix="/eotk")
portal.register_blueprint(group, url_prefix="/group")
portal.register_blueprint(list_, url_prefix="/list")

View file

@ -0,0 +1,243 @@
from typing import List, Union, Optional, Dict, Type
from flask import render_template, url_for, redirect, Blueprint
from flask.typing import ResponseReturnValue
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, SubmitField, IntegerField, BooleanField, Form, FormField
from wtforms.validators import InputRequired
from app.extensions import db
from app.models.cloud import CloudAccount, CloudProvider
bp = Blueprint("cloud", __name__)
_SECTION_TEMPLATE_VARS = {
"section": "cloud",
"help_url": "https://bypass.censorship.guide/user/cloud.html"
}
class NewCloudAccountForm(FlaskForm): # type: ignore
provider = SelectField('Cloud Provider', validators=[InputRequired()])
submit = SubmitField('Next')
class AWSAccountForm(FlaskForm): # type: ignore
provider = StringField('Platform', render_kw={"disabled": ""})
description = StringField('Description', validators=[InputRequired()])
aws_access_key = StringField('AWS Access Key', validators=[InputRequired()])
aws_secret_key = StringField('AWS Secret Key', validators=[InputRequired()])
aws_region = StringField('AWS Region', default='us-east-2', validators=[InputRequired()])
max_distributions = IntegerField('Cloudfront Distributions Quota', default=200,
description="This is the quota for number of distributions per account.",
validators=[InputRequired()])
max_instances = IntegerField('EC2 Instance Quota', default=2,
description="This can be impacted by a number of quotas including instance limits "
"and IP address limits.",
validators=[InputRequired()])
enabled = BooleanField('Enable this account', default=True,
description="New resources will not be deployed to disabled accounts, however existing "
"resources will persist until destroyed at the end of their lifecycle.")
submit = SubmitField('Save Changes')
class HcloudAccountForm(FlaskForm): # type: ignore
provider = StringField('Platform', render_kw={"disabled": ""})
description = StringField('Description', validators=[InputRequired()])
hcloud_token = StringField('Hetzner Cloud Token', validators=[InputRequired()])
max_instances = IntegerField('Server Limit', default=10,
validators=[InputRequired()])
enabled = BooleanField('Enable this account', default=True,
description="New resources will not be deployed to disabled accounts, however existing "
"resources will persist until destroyed at the end of their lifecycle.")
submit = SubmitField('Save Changes')
class OvhHorizonForm(Form): # type: ignore[misc]
ovh_openstack_user = StringField("User")
ovh_openstack_password = StringField("Password")
ovh_openstack_tenant_id = StringField("Tenant ID")
class OvhApiForm(Form): # type: ignore[misc]
ovh_cloud_application_key = StringField("Application Key")
ovh_cloud_application_secret = StringField("Application Secret")
ovh_cloud_consumer_key = StringField("Consumer Key")
class OvhAccountForm(FlaskForm): # type: ignore
provider = StringField('Platform', render_kw={"disabled": ""})
description = StringField('Description', validators=[InputRequired()])
horizon = FormField(OvhHorizonForm, 'OpenStack Horizon API')
ovh_api = FormField(OvhApiForm, 'OVH API')
max_instances = IntegerField('Server Limit', default=10,
validators=[InputRequired()])
enabled = BooleanField('Enable this account', default=True,
description="New resources will not be deployed to disabled accounts, however existing "
"resources will persist until destroyed at the end of their lifecycle.")
submit = SubmitField('Save Changes')
class GandiHorizonForm(Form): # type: ignore[misc]
gandi_openstack_user = StringField("User")
gandi_openstack_password = StringField("Password")
gandi_openstack_tenant_id = StringField("Tenant ID")
class GandiAccountForm(FlaskForm): # type: ignore
provider = StringField('Platform', render_kw={"disabled": ""})
description = StringField('Description', validators=[InputRequired()])
horizon = FormField(GandiHorizonForm, 'OpenStack Horizon API')
max_instances = IntegerField('Server Limit', default=10,
validators=[InputRequired()])
enabled = BooleanField('Enable this account', default=True,
description="New resources will not be deployed to disabled accounts, however existing "
"resources will persist until destroyed at the end of their lifecycle.")
submit = SubmitField('Save Changes')
CloudAccountForm = Union[AWSAccountForm, HcloudAccountForm, GandiAccountForm, OvhAccountForm]
provider_forms: Dict[str, Type[CloudAccountForm]] = {
CloudProvider.AWS.name: AWSAccountForm,
CloudProvider.HCLOUD.name: HcloudAccountForm,
CloudProvider.GANDI.name: GandiAccountForm,
CloudProvider.OVH.name: OvhAccountForm,
}
def cloud_account_save(account: Optional[CloudAccount], provider: CloudProvider, form: CloudAccountForm) -> None:
if not account:
account = CloudAccount()
account.provider = provider
db.session.add(account)
if account.provider != provider:
raise RuntimeError("Provider mismatch in saving cloud account.")
account.description = form.description.data
account.enabled = form.enabled.data
if provider == CloudProvider.AWS and isinstance(form, AWSAccountForm):
account.credentials = {
"aws_access_key": form.aws_access_key.data,
"aws_secret_key": form.aws_secret_key.data,
"aws_region": form.aws_region.data,
}
account.max_distributions = form.max_distributions.data
account.max_sub_distributions = 1
account.max_instances = form.max_instances.data
elif provider == CloudProvider.HCLOUD and isinstance(form, HcloudAccountForm):
account.credentials = {
"hcloud_token": form.hcloud_token.data,
}
account.max_distributions = 0
account.max_sub_distributions = 0
account.max_instances = form.max_instances.data
elif provider == CloudProvider.GANDI and isinstance(form, GandiAccountForm):
account.credentials = {
"gandi_openstack_user": form.horizon.data["gandi_openstack_user"],
"gandi_openstack_password": form.horizon.data["gandi_openstack_password"],
"gandi_openstack_tenant_id": form.horizon.data["gandi_openstack_tenant_id"],
}
account.max_distributions = 0
account.max_sub_distributions = 0
account.max_instances = form.max_instances.data
elif provider == CloudProvider.OVH and isinstance(form, OvhAccountForm):
account.credentials = {
"ovh_openstack_user": form.horizon.data["ovh_openstack_user"],
"ovh_openstack_password": form.horizon.data["ovh_openstack_password"],
"ovh_openstack_tenant_id": form.horizon.data["ovh_openstack_tenant_id"],
"ovh_cloud_application_key": form.ovh_api.data["ovh_cloud_application_key"],
"ovh_cloud_application_secret": form.ovh_api.data["ovh_cloud_application_secret"],
"ovh_cloud_consumer_key": form.ovh_api.data["ovh_cloud_consumer_key"],
}
account.max_distributions = 0
account.max_sub_distributions = 0
account.max_instances = form.max_instances.data
else:
raise RuntimeError("Unknown provider or form data did not match provider.")
def cloud_account_populate(form: CloudAccountForm, account: CloudAccount) -> None:
form.provider.data = account.provider.description
form.description.data = account.description
form.enabled.data = account.enabled
if account.provider == CloudProvider.AWS and isinstance(form, AWSAccountForm):
form.aws_access_key.data = account.credentials["aws_access_key"]
form.aws_secret_key.data = account.credentials["aws_secret_key"]
form.aws_region.data = account.credentials["aws_region"]
form.max_distributions.data = account.max_distributions
form.max_instances.data = account.max_instances
elif account.provider == CloudProvider.HCLOUD and isinstance(form, HcloudAccountForm):
form.hcloud_token.data = account.credentials["hcloud_token"]
form.max_instances.data = account.max_instances
elif account.provider == CloudProvider.GANDI and isinstance(form, GandiAccountForm):
form.horizon.form.gandi_openstack_user.data = account.credentials["gandi_openstack_user"]
form.horizon.form.gandi_openstack_password.data = account.credentials["gandi_openstack_password"]
form.horizon.form.gandi_openstack_tenant_id.data = account.credentials["gandi_openstack_tenant_id"]
form.max_instances.data = account.max_instances
elif account.provider == CloudProvider.OVH and isinstance(form, OvhAccountForm):
form.horizon.form.ovh_openstack_user.data = account.credentials["ovh_openstack_user"]
form.horizon.form.ovh_openstack_password.data = account.credentials["ovh_openstack_password"]
form.horizon.form.ovh_openstack_tenant_id.data = account.credentials["ovh_openstack_tenant_id"]
form.ovh_api.form.ovh_cloud_application_key.data = account.credentials["ovh_cloud_application_key"]
form.ovh_api.form.ovh_cloud_application_secret.data = account.credentials["ovh_cloud_application_secret"]
form.ovh_api.form.ovh_cloud_consumer_key.data = account.credentials["ovh_cloud_consumer_key"]
form.max_instances.data = account.max_instances
else:
raise RuntimeError(f"Unknown provider {account.provider} or form data {type(form)} did not match provider.")
@bp.route("/list")
def cloud_account_list() -> ResponseReturnValue:
accounts: List[CloudAccount] = CloudAccount.query.filter(CloudAccount.destroyed.is_(None)).all()
return render_template("list.html.j2",
title="Cloud Accounts",
item="cloud account",
items=accounts,
new_link=url_for("portal.cloud.cloud_account_new"),
**_SECTION_TEMPLATE_VARS)
@bp.route("/new", methods=['GET', 'POST'])
def cloud_account_new() -> ResponseReturnValue:
form = NewCloudAccountForm()
form.provider.choices = sorted([
(provider.name, provider.description) for provider in CloudProvider
], key=lambda p: p[1].lower()) # type: ignore[no-any-return]
if form.validate_on_submit():
return redirect(url_for("portal.cloud.cloud_account_new_for", provider=form.provider.data))
return render_template("new.html.j2",
form=form,
**_SECTION_TEMPLATE_VARS)
@bp.route("/new/<provider>", methods=['GET', 'POST'])
def cloud_account_new_for(provider: str) -> ResponseReturnValue:
form = provider_forms[provider]()
form.provider.data = CloudProvider[provider].description
if form.validate_on_submit():
cloud_account_save(None, CloudProvider[provider], form)
db.session.commit()
return redirect(url_for("portal.cloud.cloud_account_list"))
return render_template("new.html.j2",
form=form,
**_SECTION_TEMPLATE_VARS)
@bp.route("/edit/<account_id>", methods=['GET', 'POST'])
def cloud_account_edit(account_id: int) -> ResponseReturnValue:
account = CloudAccount.query.filter(
CloudAccount.id == account_id,
CloudAccount.destroyed.is_(None),
).first()
if not account:
return "Not found", 404
form = provider_forms[account.provider.name]()
if form.validate_on_submit():
cloud_account_save(account, account.provider, form)
print(account.description)
db.session.commit()
return redirect(url_for("portal.cloud.cloud_account_list"))
cloud_account_populate(form, account)
return render_template("new.html.j2",
form=form,
**_SECTION_TEMPLATE_VARS)

View file

@ -81,6 +81,12 @@
<span>Configuration</span>
</h6>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link{% if section == "cloud" %} active{% else %} disabled text-secondary{% endif %}"
href="{{ url_for("portal.cloud.cloud_account_list") }}">
{{ icon("cloud") }} Cloud Accounts
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if section == "pool" %} active{% endif %}"
href="{{ url_for("portal.pool.pool_list") }}">

View file

@ -16,6 +16,10 @@
viewBox="0 0 16 16">
<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"/>
</svg>
{% elif i == "cloud" %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud" viewBox="0 0 16 16">
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
</svg>
{% elif i == "collection" %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-collection"
viewBox="0 0 16 16">

View file

@ -1,7 +1,7 @@
{% extends "base.html.j2" %}
{% from "tables.html.j2" import alarms_table, automations_table, bridgeconfs_table, bridges_table, eotk_table,
groups_table, instances_table, mirrorlists_table, origins_table, origin_onion_table, onions_table, pools_table,
proxies_table, webhook_table %}
{% from "tables.html.j2" import alarms_table, automations_table, bridgeconfs_table, bridges_table, cloud_accounts_table,
eotk_table, groups_table, instances_table, mirrorlists_table, origins_table, origin_onion_table, onions_table,
pools_table, proxies_table, webhook_table %}
{% block content %}
<h1 class="h2 mt-3">{{ title }}</h1>
@ -19,6 +19,8 @@
{{ bridgeconfs_table(items) }}
{% elif item == "bridge" %}
{{ bridges_table(items) }}
{% elif item == "cloud account" %}
{{ cloud_accounts_table(items) }}
{% elif item == "eotk" %}
{{ eotk_table(items) }}
{% elif item == "group" %}

View file

@ -185,6 +185,34 @@
</div>
{% endmacro %}
{% macro cloud_accounts_table(accounts) %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Provider</th>
<th scope="col">Description</th>
<th scope="col">Enabled</th>
</tr>
</thead>
<tbody>
{% for account in accounts %}
{% if not account.destroyed %}
<tr class="align-middle">
<td>{{ account.provider.description }}</td>
<td>{{ account.description }}</td>
<td>{% if account.enabled %}✅{% else %}❌{% endif %}</td>
<td>
<a href="{{ url_for("portal.cloud.cloud_account_edit", account_id=account.id) }}" class="btn btn-primary btn-sm">View/Edit</a>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}
{% macro groups_table(groups, pool=None) %}
<div class="table-responsive">
<table class="table table-striped table-sm">
@ -491,10 +519,11 @@
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Pool</th>
<th scope="col">Configuration</th>
<th scope="col">Nickname</th>
<th scope="col">Hashed Fingerprint</th>
<th scope="col">Pool</th>
<th scope="col">Configuration</th>
<th scope="col">Cloud Account</th>
<th scope="col">Alarms</th>
<th scope="col">Actions</th>
</tr>
@ -503,10 +532,6 @@
{% for bridge in bridges %}
{% if not bridge.destroyed %}
<tr class="align-middle{% if bridge.deprecated %} bg-warning{% endif %}">
<td>
<a href="{{ url_for("portal.pool.pool_edit", pool_id=bridge.conf.pool_id) }}">{{ bridge.conf.pool.pool_name }}</a>
</td>
<td>{{ bridge.conf.description }} ({{ bridge.provider }}/{{ bridge.conf.method }})</td>
<td>
<a href="https://metrics.torproject.org/rs.html#details/{{ bridge.hashed_fingerprint }}">
{{ bridge.nickname }}
@ -515,6 +540,15 @@
<td>
<code>{{ bridge.hashed_fingerprint }}</code>
</td>
<td>
<a href="{{ url_for("portal.pool.pool_edit", pool_id=bridge.conf.pool_id) }}">{{ bridge.conf.pool.pool_name }}</a>
</td>
<td>{{ bridge.conf.description }} ({{ bridge.conf.method }})</td>
<td>
<a href="{{ url_for("portal.cloud.cloud_account_edit", account_id=bridge.cloud_account_id) }}">
{{ bridge.cloud_account.provider.description }} ({{ bridge.cloud_account.description }})
</a>
</td>
<td>
{% for alarm in bridge.alarms %}
<span title="{{ alarm.alarm_type }}">

View file

@ -1,14 +1,38 @@
import datetime
import os
import sys
from typing import Optional, Any, List
from typing import Optional, Any, List, Tuple
from sqlalchemy import select
from app import app
from app.extensions import db
from app.models.bridges import BridgeConf, Bridge
from app.models.base import Group
from app.models.bridges import Bridge, BridgeConf
from app.models.cloud import CloudAccount, CloudProvider
from app.terraform.terraform import TerraformAutomation
BridgeResourceRow = List[Tuple[Bridge, BridgeConf, CloudAccount]]
def active_bridges_by_provider(provider: CloudProvider) -> List[BridgeResourceRow]:
stmt = select(Bridge, BridgeConf, CloudAccount).join_from(Bridge, BridgeConf).join_from(Bridge, CloudAccount).where(
CloudAccount.provider == provider,
Bridge.destroyed.is_(None),
)
bridges: List[BridgeResourceRow] = db.session.execute(stmt).all()
return bridges
def recently_destroyed_bridges_by_provider(provider: CloudProvider) -> List[BridgeResourceRow]:
cutoff = datetime.datetime.utcnow() - datetime.timedelta(hours=72)
stmt = select(Bridge, BridgeConf, CloudAccount).join_from(Bridge, BridgeConf).join_from(Bridge, CloudAccount).where(
CloudAccount.provider == provider,
Bridge.destroyed.is_not(None),
Bridge.destroyed >= cutoff,
)
bridges: List[BridgeResourceRow] = db.session.execute(stmt).all()
return bridges
class BridgeAutomation(TerraformAutomation):
template: str
@ -33,11 +57,8 @@ class BridgeAutomation(TerraformAutomation):
def tf_generate(self) -> None:
self.tf_write(
self.template,
groups=Group.query.all(),
bridgeconfs=BridgeConf.query.filter(
BridgeConf.destroyed.is_(None),
BridgeConf.provider == self.provider
).all(),
active_resources=active_bridges_by_provider(self.provider),
destroyed_resources=recently_destroyed_bridges_by_provider(self.provider),
global_namespace=app.config['GLOBAL_NAMESPACE'],
terraform_modules_path=os.path.join(*list(os.path.split(app.root_path))[:-1], 'terraform-modules'),
backend_config=f"""backend "http" {{

View file

@ -1,15 +1,15 @@
from app.models.cloud import CloudProvider
from app.terraform.bridge import BridgeAutomation
class BridgeAWSAutomation(BridgeAutomation):
short_name = "bridge_aws"
description = "Deploy Tor bridges on AWS Lightsail"
provider = "aws"
description = "Deploy Tor bridges on AWS EC2"
provider = CloudProvider.AWS
template_parameters = [
"aws_access_key",
"aws_secret_key",
"ssh_public_key_path"
"ssh_public_key_path",
"ssh_private_key_path",
]
template = """
@ -22,37 +22,42 @@ class BridgeAWSAutomation(BridgeAutomation):
}
}
provider "aws" {
access_key = "{{ aws_access_key }}"
secret_key = "{{ aws_secret_key }}"
region = "us-east-1"
}
locals {
ssh_key = file("{{ ssh_public_key_path }}")
ssh_public_key = "{{ ssh_public_key_path }}"
ssh_private_key = "{{ ssh_public_key_path }}"
}
{% for group in groups %}
module "label_{{ group.id }}" {
source = "cloudposse/label/null"
version = "0.25.0"
namespace = "{{ global_namespace }}"
tenant = "{{ group.group_name }}"
label_order = ["namespace", "tenant", "name", "attributes"]
{% for resource in destroyed_resources %}
{% set bridge, bridgeconf, account = resource %}
provider "aws" {
access_key = "{{ account.credentials['aws_access_key'] }}"
secret_key = "{{ account.credentials['aws_secret_key'] }}"
region = "{{ account.credentials['aws_region'] }}"
alias = "account_{{ bridge.id }}"
}
{% endfor %}
{% for bridgeconf in bridgeconfs %}
{% for bridge in bridgeconf.bridges %}
{% if not bridge.destroyed %}
{% for resource in resources %}
{% set bridge, bridgeconf, account = resource %}
provider "aws" {
access_key = "{{ account.credentials['aws_access_key'] }}"
secret_key = "{{ account.credentials['aws_secret_key'] }}"
region = "{{ account.credentials['aws_region'] }}"
alias = "account_{{ bridge.id }}"
}
module "bridge_{{ bridge.id }}" {
providers = {
aws = aws.account_{{ bridge.id }}
}
source = "{{ terraform_modules_path }}/terraform-aws-tor-bridge"
ssh_key = local.ssh_key
ssh_public_key = local.ssh_public_key
ssh_private_key = local.ssh_private_key
contact_info = "hi"
context = module.label_{{ bridgeconf.group.id }}.context
name = "br"
namespace = "{{ global_namespace }}"
name = "bridge"
attributes = ["{{ bridge.id }}"]
distribution_method = "{{ bridge.conf.method }}"
distribution_method = "{{ bridgeconf.method }}"
}
output "bridge_hashed_fingerprint_{{ bridge.id }}" {
@ -63,7 +68,5 @@ class BridgeAWSAutomation(BridgeAutomation):
value = module.bridge_{{ bridge.id }}.bridgeline
sensitive = true
}
{% endif %}
{% endfor %}
{% endfor %}
"""

View file

@ -1,15 +1,13 @@
from app.models.cloud import CloudProvider
from app.terraform.bridge import BridgeAutomation
class BridgeGandiAutomation(BridgeAutomation):
short_name = "bridge_gandi"
description = "Deploy Tor bridges on GandiCloud VPS"
provider = "gandi"
provider = CloudProvider.GANDI
template_parameters = [
"gandi_openstack_user",
"gandi_openstack_password",
"gandi_openstack_tenant_name",
"ssh_public_key_path",
"ssh_private_key_path"
]
@ -25,43 +23,50 @@ class BridgeGandiAutomation(BridgeAutomation):
}
}
locals {
public_ssh_key = "{{ ssh_public_key_path }}"
private_ssh_key = "{{ ssh_private_key_path }}"
}
{% for resource in destroyed_resources %}
{% set bridge, bridgeconf, account = resource %}
provider "openstack" {
auth_url = "https://keystone.sd6.api.gandi.net:5000/v3"
user_domain_name = "public"
project_domain_name = "public"
user_name = "{{ gandi_openstack_user }}"
password = "{{ gandi_openstack_password }}"
tenant_name = "{{ gandi_openstack_tenant_name }}"
user_name = "{{ account.credentials["gandi_openstack_user"] }}"
password = "{{ account.credentials["gandi_openstack_password"] }}"
tenant_name = "{{ account.credentials["gandi_openstack_tenant_id"] }}"
region = "FR-SD6"
}
locals {
public_ssh_key = file("{{ ssh_public_key_path }}")
private_ssh_key = file("{{ ssh_private_key_path }}")
}
{% for group in groups %}
module "label_{{ group.id }}" {
source = "cloudposse/label/null"
version = "0.25.0"
namespace = "{{ global_namespace }}"
tenant = "{{ group.group_name }}"
label_order = ["namespace", "tenant", "name", "attributes"]
alias = "account_{{ bridge.id }}"
}
{% endfor %}
{% for bridgeconf in bridgeconfs %}
{% for bridge in bridgeconf.bridges %}
{% if not bridge.destroyed %}
{% for resource in active_resources %}
{% set bridge, bridgeconf, account = resource %}
provider "openstack" {
auth_url = "https://keystone.sd6.api.gandi.net:5000/v3"
user_domain_name = "public"
project_domain_name = "public"
user_name = "{{ account.credentials["gandi_openstack_user"] }}"
password = "{{ account.credentials["gandi_openstack_password"] }}"
tenant_name = "{{ account.credentials["gandi_openstack_tenant_id"] }}"
region = "FR-SD6"
alias = "account_{{ bridge.id }}"
}
module "bridge_{{ bridge.id }}" {
providers = {
openstack = openstack.account_{{ bridge.id }}
}
source = "{{ terraform_modules_path }}/terraform-openstack-tor-bridge"
context = module.label_{{ bridgeconf.group.id }}.context
name = "br"
namespace = "{{ global_namespace }}"
name = "bridge"
attributes = ["{{ bridge.id }}"]
ssh_key = local.public_ssh_key
ssh_private_key = local.private_ssh_key
contact_info = "hi"
distribution_method = "{{ bridge.conf.method }}"
contact_info = "did not write the code to populate yet"
distribution_method = "{{ bridgeconf.method }}"
image_name = "Debian 11 Bullseye"
flavor_name = "V-R1"
@ -77,7 +82,5 @@ class BridgeGandiAutomation(BridgeAutomation):
value = module.bridge_{{ bridge.id }}.bridgeline
sensitive = true
}
{% endif %}
{% endfor %}
{% endfor %}
"""

View file

@ -1,14 +1,15 @@
from app.models.cloud import CloudProvider
from app.terraform.bridge import BridgeAutomation
class BridgeHcloudAutomation(BridgeAutomation):
short_name = "bridge_hcloud"
description = "Deploy Tor bridges on Hetzner Cloud"
provider = "hcloud"
provider = CloudProvider.HCLOUD
template_parameters = [
"hcloud_token",
"ssh_private_key_path"
"ssh_private_key_path",
"ssh_public_key_path"
]
template = """
@ -26,36 +27,36 @@ class BridgeHcloudAutomation(BridgeAutomation):
}
}
provider "hcloud" {
token = "{{ hcloud_token }}"
}
locals {
ssh_private_key = file("{{ ssh_private_key_path }}")
ssh_private_key = "{{ ssh_private_key_path }}"
}
data "hcloud_datacenters" "ds" {
}
data "hcloud_server_type" "cx11" {
name = "cx11"
}
{% for group in groups %}
module "label_{{ group.id }}" {
source = "cloudposse/label/null"
version = "0.25.0"
namespace = "{{ global_namespace }}"
tenant = "{{ group.group_name }}"
label_order = ["namespace", "tenant", "name", "attributes"]
{% for resource in destroyed_resources %}
{% set bridge, bridgeconf, account = resource %}
provider "hcloud" {
token = "{{ account.credentials["hcloud_token"] }}"
alias = "account_{{ bridge.id }}"
}
{% endfor %}
{% for bridgeconf in bridgeconfs %}
{% for bridge in bridgeconf.bridges %}
{% if not bridge.destroyed %}
{% for resource in active_resources %}
{% set bridge, bridgeconf, account = resource %}
provider "hcloud" {
token = "{{ account.credentials["hcloud_token"] }}"
alias = "account_{{ bridge.id }}"
}
data "hcloud_datacenters" "ds_{{ bridge.id }}" {
provider = hcloud.account_{{ bridge.id }}
}
data "hcloud_server_type" "cx11_{{ bridge.id }}" {
provider = hcloud.account_{{ bridge.id }}
name = "cx11"
}
resource "random_shuffle" "datacenter_{{ bridge.id }}" {
input = [for s in data.hcloud_datacenters.ds.datacenters : s.name if contains(s.available_server_type_ids, data.hcloud_server_type.cx11.id)]
input = [for s in data.hcloud_datacenters.ds_{{ bridge.id }}.datacenters : s.name if contains(s.available_server_type_ids, data.hcloud_server_type.cx11_{{ bridge.id }}.id)]
result_count = 1
lifecycle {
@ -64,15 +65,17 @@ class BridgeHcloudAutomation(BridgeAutomation):
}
module "bridge_{{ bridge.id }}" {
providers = {
hcloud = hcloud.account_{{ bridge.id }}
}
source = "{{ terraform_modules_path }}/terraform-hcloud-tor-bridge"
datacenter = one(random_shuffle.datacenter_{{ bridge.id }}.result)
context = module.label_{{ bridgeconf.group.id }}.context
name = "br"
namespace = "{{ global_namespace }}"
name = "bridge"
attributes = ["{{ bridge.id }}"]
ssh_key_name = "bc"
ssh_private_key = local.ssh_private_key
contact_info = "hi"
distribution_method = "{{ bridge.conf.method }}"
contact_info = "this used to be sanitised and I did not write the code to populate it yet"
distribution_method = "{{ bridgeconf.method }}"
}
output "bridge_hashed_fingerprint_{{ bridge.id }}" {
@ -83,7 +86,5 @@ class BridgeHcloudAutomation(BridgeAutomation):
value = module.bridge_{{ bridge.id }}.bridgeline
sensitive = true
}
{% endif %}
{% endfor %}
{% endfor %}
"""

View file

@ -4,18 +4,40 @@ from typing import Tuple, List
from app import db
from app.models.bridges import BridgeConf, Bridge
from app.models.cloud import CloudProvider, CloudAccount
from app.terraform import BaseAutomation
from app.terraform.bridge.gandi import BridgeGandiAutomation
from app.terraform.bridge.hcloud import BridgeHcloudAutomation
from app.terraform.bridge.ovh import BridgeOvhAutomation
BRIDGE_PROVIDERS = {p.provider: p for p in [
BRIDGE_PROVIDERS = [
# In order of cost
BridgeHcloudAutomation,
BridgeGandiAutomation,
BridgeOvhAutomation,
# BridgeAWSAutomation, TODO: This module is broken right now
] if p.enabled}
CloudProvider.HCLOUD,
CloudProvider.GANDI,
CloudProvider.OVH,
CloudProvider.AWS,
]
def active_bridges_in_account(account: CloudAccount) -> List[Bridge]:
bridges: List[Bridge] = Bridge.query.filter(
Bridge.cloud_account_id == account.id,
Bridge.destroyed.is_(None),
).all()
return bridges
def create_bridges_in_account(bridgeconf: BridgeConf, account: CloudAccount, count: int) -> int:
created = 0
while created < count and len(active_bridges_in_account(account)) < account.max_instances:
logging.debug("Creating bridge for configuration %s in account %s", bridgeconf.id, account)
bridge = Bridge()
bridge.pool_id = bridgeconf.pool.id
bridge.conf_id = bridgeconf.id
bridge.cloud_account = account
bridge.added = datetime.datetime.utcnow()
bridge.updated = datetime.datetime.utcnow()
logging.debug("Creating bridge %s", bridge)
db.session.add(bridge)
created += 1
return created
def create_bridges(bridgeconf: BridgeConf, count: int) -> int:
@ -24,24 +46,17 @@ def create_bridges(bridgeconf: BridgeConf, count: int) -> int:
"""
logging.debug("Creating %s bridges for configuration %s", count, bridgeconf.id)
created = 0
# TODO: deal with the fact that I created a dictionary and then forgot it wasn't ordered
while created < count:
for provider in BRIDGE_PROVIDERS:
if BRIDGE_PROVIDERS[provider].max_bridges > BRIDGE_PROVIDERS[provider].active_bridges_count():
logging.debug("Creating bridge for configuration %s with provider %s", bridgeconf.id, provider)
bridge = Bridge()
bridge.pool_id = bridgeconf.pool.id
bridge.conf_id = bridgeconf.id
bridge.provider = provider
bridge.added = datetime.datetime.utcnow()
bridge.updated = datetime.datetime.utcnow()
logging.debug("Creating bridge %s", bridge)
db.session.add(bridge)
created += 1
if created >= count:
break
else:
logging.debug("No provider has available quota to create missing bridge for configuration %s",
bridgeconf.id)
logging.info("Creating bridges in %s accounts", provider.description)
for account in CloudAccount.query.filter(
CloudAccount.destroyed.is_(None),
CloudAccount.enabled.is_(True),
CloudAccount.provider == provider,
):
logging.info("Creating bridges in %s", account)
created += create_bridges_in_account(bridgeconf, account, count - created)
logging.debug("Created %s bridges", created)
return created

View file

@ -1,18 +1,13 @@
from app.models.cloud import CloudProvider
from app.terraform.bridge import BridgeAutomation
class BridgeOvhAutomation(BridgeAutomation):
short_name = "bridge_ovh"
description = "Deploy Tor bridges on OVH Public Cloud"
provider = "ovh"
provider = CloudProvider.OVH
template_parameters = [
"ovh_cloud_application_key",
"ovh_cloud_application_secret",
"ovh_cloud_consumer_key",
"ovh_openstack_user",
"ovh_openstack_password",
"ovh_openstack_tenant_id",
"ssh_public_key_path",
"ssh_private_key_path"
]
@ -36,46 +31,50 @@ class BridgeOvhAutomation(BridgeAutomation):
}
}
locals {
public_ssh_key = "{{ ssh_public_key_path }}"
private_ssh_key = "{{ ssh_private_key_path }}"
}
{% for resource in destroyed_resources %}
{% set bridge, bridgeconf, account = resource %}
provider "openstack" {
auth_url = "https://auth.cloud.ovh.net/v3/"
domain_name = "Default" # Domain name - Always at 'default' for OVHcloud
user_name = "{{ ovh_openstack_user }}"
password = "{{ ovh_openstack_password }}"
tenant_id = "{{ ovh_openstack_tenant_id }}"
user_name = "{{ account.credentials["ovh_openstack_user"] }}"
password = "{{ account.credentials["ovh_openstack_password"] }}"
tenant_id = "{{ account.credentials["ovh_openstack_tenant_id"] }}"
alias = "account_{{ bridge.id }}"
}
{% endfor %}
{% for resource in active_resources %}
{% set bridge, bridgeconf, account = resource %}
provider "openstack" {
auth_url = "https://auth.cloud.ovh.net/v3/"
domain_name = "Default" # Domain name - Always at 'default' for OVHcloud
user_name = "{{ account.credentials["ovh_openstack_user"] }}"
password = "{{ account.credentials["ovh_openstack_password"] }}"
tenant_id = "{{ account.credentials["ovh_openstack_tenant_id"] }}"
alias = "account_{{ bridge.id }}"
}
provider "ovh" {
endpoint = "ovh-eu"
application_key = "{{ ovh_cloud_application_key }}"
application_secret = "{{ ovh_cloud_application_secret }}"
consumer_key = "{{ ovh_cloud_consumer_key }}"
application_key = "{{ account.credentials["ovh_cloud_application_key"] }}"
application_secret = "{{ account.credentials["ovh_cloud_application_secret"] }}"
consumer_key = "{{ account.credentials["ovh_cloud_consumer_key"] }}"
alias = "account_{{ bridge.id }}"
}
locals {
public_ssh_key = file("{{ ssh_public_key_path }}")
private_ssh_key = file("{{ ssh_private_key_path }}")
}
data "ovh_cloud_project_regions" "regions" {
service_name = "{{ ovh_openstack_tenant_id }}"
data "ovh_cloud_project_regions" "regions_{{ bridge.id }}" {
provider = ovh.account_{{ bridge.id }}
service_name = "{{ account.credentials["ovh_openstack_tenant_id"] }}"
has_services_up = ["instance"]
}
{% for group in groups %}
module "label_{{ group.id }}" {
source = "cloudposse/label/null"
version = "0.25.0"
namespace = "{{ global_namespace }}"
tenant = "{{ group.group_name }}"
label_order = ["namespace", "tenant", "name", "attributes"]
}
{% endfor %}
{% for bridgeconf in bridgeconfs %}
{% for bridge in bridgeconf.bridges %}
{% if not bridge.destroyed %}
resource "random_shuffle" "region_{{ bridge.id }}" {
input = data.ovh_cloud_project_regions.regions.names
input = data.ovh_cloud_project_regions.regions_{{ bridge.id }}.names
result_count = 1
lifecycle {
@ -84,15 +83,18 @@ class BridgeOvhAutomation(BridgeAutomation):
}
module "bridge_{{ bridge.id }}" {
providers = {
openstack = openstack.account_{{ bridge.id }}
}
source = "{{ terraform_modules_path }}/terraform-openstack-tor-bridge"
region = one(random_shuffle.region_{{ bridge.id }}.result)
context = module.label_{{ bridgeconf.group.id }}.context
name = "br"
namespace = "{{ global_namespace }}"
name = "bridge"
attributes = ["{{ bridge.id }}"]
ssh_key = local.public_ssh_key
ssh_private_key = local.private_ssh_key
contact_info = "hi"
distribution_method = "{{ bridge.conf.method }}"
contact_info = "hello from onionland"
distribution_method = "{{ bridgeconf.method }}"
}
output "bridge_hashed_fingerprint_{{ bridge.id }}" {
@ -103,7 +105,5 @@ class BridgeOvhAutomation(BridgeAutomation):
value = module.bridge_{{ bridge.id }}.bridgeline
sensitive = true
}
{% endif %}
{% endfor %}
{% endfor %}
"""

View file

@ -43,6 +43,9 @@ class ListAutomation(TerraformAutomation):
in the templating of the Terraform configuration.
"""
provider: str # type: ignore[assignment]
# TODO: remove temporary override
def tf_generate(self) -> None:
if not self.working_dir:
raise RuntimeError("No working directory specified.")

View file

@ -71,6 +71,9 @@ class ProxyAutomation(TerraformAutomation):
The name of the cloud provider used by this proxy provider. Used to determine if this provider can be enabled.
"""
provider: str # type: ignore[assignment]
# TODO: Temporary override
@abstractmethod
def import_state(self, state: Any) -> None:
raise NotImplementedError()

View file

@ -3,6 +3,7 @@ import subprocess # nosec
from abc import abstractmethod
from typing import Any, Optional, Tuple
from app.models.cloud import CloudProvider
from app.terraform import BaseAutomation
@ -22,7 +23,7 @@ class TerraformAutomation(BaseAutomation):
Default parallelism for remote API calls.
"""
provider: str
provider: CloudProvider
"""
Short name for the provider used by this module.
"""

View file

@ -0,0 +1,64 @@
"""Abstracting cloud providers
Revision ID: 431704bac57a
Revises: 89a74e347d85
Create Date: 2023-01-27 13:32:39.933555
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '431704bac57a'
down_revision = '89a74e347d85'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('cloud_account',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=False),
sa.Column('added', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False),
sa.Column('destroyed', sa.DateTime(), nullable=True),
sa.Column('provider',
sa.Enum('AWS', 'AZURE', 'BUNNY', 'CLOUDFLARE', 'FASTLY', 'HTTP', 'GANDI', 'GITHUB',
'GITLAB', 'HCLOUD', 'MAXMIND', 'OVH', 'RFC2136', name='cloudprovider'),
nullable=True),
sa.Column('credentials', sa.JSON(), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=True),
sa.Column('max_distributions', sa.Integer(), nullable=True),
sa.Column('max_sub_distributions', sa.Integer(), nullable=True),
sa.Column('max_instances', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_cloud_account'))
)
with op.batch_alter_table('bridge', schema=None) as batch_op:
batch_op.add_column(sa.Column('cloud_account_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key(batch_op.f('fk_bridge_cloud_account_id_cloud_account'), 'cloud_account',
['cloud_account_id'], ['id'])
batch_op.drop_column('provider')
def downgrade():
op.drop_table('bridge') # We can't guess at what the old providers would have been easily
op.create_table('bridge',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('added', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False),
sa.Column('deprecated', sa.DateTime(), nullable=True),
sa.Column('deprecation_reason', sa.String(), nullable=True),
sa.Column('destroyed', sa.DateTime(), nullable=True),
sa.Column('conf_id', sa.Integer(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('terraform_updated', sa.DateTime(), nullable=True),
sa.Column('nickname', sa.String(length=255), nullable=True),
sa.Column('fingerprint', sa.String(length=255), nullable=True),
sa.Column('hashed_fingerprint', sa.String(length=255), nullable=True),
sa.Column('bridgeline', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['conf_id'], ['bridge_conf.id'],
name=op.f('fk_bridge_conf_id_bridge_conf')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_bridge'))
)
op.drop_table('cloud_account')