diff --git a/app/models/bridges.py b/app/models/bridges.py index 7f97de7..bc774c8 100644 --- a/app/models/bridges.py +++ b/app/models/bridges.py @@ -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) ) diff --git a/app/models/cloud.py b/app/models/cloud.py index e69de29..dc04e6c 100644 --- a/app/models/cloud.py +++ b/app/models/cloud.py @@ -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") diff --git a/app/portal/__init__.py b/app/portal/__init__.py index 82618fe..d1ab73f 100644 --- a/app/portal/__init__.py +++ b/app/portal/__init__.py @@ -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") diff --git a/app/portal/cloud.py b/app/portal/cloud.py index e69de29..19de7cb 100644 --- a/app/portal/cloud.py +++ b/app/portal/cloud.py @@ -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/", 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/", 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) diff --git a/app/portal/templates/base.html.j2 b/app/portal/templates/base.html.j2 index 3b2f09e..0c3ace0 100644 --- a/app/portal/templates/base.html.j2 +++ b/app/portal/templates/base.html.j2 @@ -81,6 +81,12 @@ Configuration