diff --git a/app/cli/db.py b/app/cli/db.py index 0691951..a1d81dc 100644 --- a/app/cli/db.py +++ b/app/cli/db.py @@ -54,7 +54,7 @@ def impot(model: db.Model) -> None: line[i] = None # type: ignore else: line[i] = datetime.datetime.strptime(line[i], "%Y-%m-%d %H:%M:%S.%f") # type: ignore - elif header[i] in ["eotk"]: + elif header[i] in ["eotk", "auto_rotation", "smart"]: # boolean fields line[i] = line[i] == "True" # type: ignore elif header[i].endswith("_id") and line[i] == "": diff --git a/app/models/base.py b/app/models/base.py index 12ca543..d7fb5b6 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -13,6 +13,7 @@ class Group(AbstractConfiguration): bridgeconfs = db.relationship("BridgeConf", back_populates="group") eotks = db.relationship("Eotk", back_populates="group") onions = db.relationship("Onion", back_populates="group") + smart_proxies = db.relationship("SmartProxy", back_populates="group") @classmethod def csv_header(cls) -> List[str]: diff --git a/app/models/mirrors.py b/app/models/mirrors.py index b305b50..91da8f2 100644 --- a/app/models/mirrors.py +++ b/app/models/mirrors.py @@ -12,6 +12,7 @@ class Origin(AbstractConfiguration): group_id = db.Column(db.Integer, db.ForeignKey("group.id"), nullable=False) domain_name = db.Column(db.String(255), unique=True, nullable=False) auto_rotation = db.Column(db.Boolean, nullable=False) + smart = db.Column(db.Boolean(), nullable=False) group = db.relationship("Group", back_populates="origins") proxies = db.relationship("Proxy", back_populates="origin") @@ -23,7 +24,7 @@ class Origin(AbstractConfiguration): @classmethod def csv_header(cls) -> List[str]: return super().csv_header() + [ - "group_id", "domain_name" + "group_id", "domain_name", "auto_rotation", "smart" ] def destroy(self) -> None: @@ -59,3 +60,16 @@ class Proxy(AbstractResource): return super().csv_header() + [ "origin_id", "provider", "psg", "slug", "terraform_updated", "url" ] + + +class SmartProxy(AbstractResource): + group_id = db.Column(db.Integer(), db.ForeignKey("group.id"), nullable=False) + instance_id = db.Column(db.String(100), nullable=True) + provider = db.Column(db.String(20), nullable=False) + region = db.Column(db.String(20), nullable=False) + + group = db.relationship("Group", back_populates="smart_proxies") + + @property + def brn(self) -> str: + return f"brn:{current_app.config['GLOBAL_NAMESPACE']}:{self.group_id}:mirror:{self.provider}:smart-proxy/1" diff --git a/app/portal/__init__.py b/app/portal/__init__.py index b7c8231..6257042 100644 --- a/app/portal/__init__.py +++ b/app/portal/__init__.py @@ -21,6 +21,7 @@ from app.portal.list import bp as list_ from app.portal.origin import bp as origin from app.portal.onion import bp as onion from app.portal.proxy import bp as proxy +from app.portal.smart_proxy import bp as smart_proxy from app.portal.webhook import bp as webhook portal = Blueprint("portal", __name__, template_folder="templates", static_folder="static") @@ -33,6 +34,7 @@ portal.register_blueprint(list_, url_prefix="/list") portal.register_blueprint(origin, url_prefix="/origin") portal.register_blueprint(onion, url_prefix="/onion") portal.register_blueprint(proxy, url_prefix="/proxy") +portal.register_blueprint(smart_proxy, url_prefix="/smart") portal.register_blueprint(webhook, url_prefix="/webhook") diff --git a/app/portal/origin.py b/app/portal/origin.py index 67182a1..1c7b496 100644 --- a/app/portal/origin.py +++ b/app/portal/origin.py @@ -21,6 +21,7 @@ class NewOriginForm(FlaskForm): # type: ignore description = StringField('Description', validators=[DataRequired()]) group = SelectField('Group', validators=[DataRequired()]) auto_rotate = BooleanField("Enable auto-rotation?", default=True) + smart_proxy = BooleanField("Requires smart proxy?", default=False) submit = SubmitField('Save Changes') @@ -28,6 +29,7 @@ class EditOriginForm(FlaskForm): # type: ignore description = StringField('Description', validators=[DataRequired()]) group = SelectField('Group', validators=[DataRequired()]) auto_rotate = BooleanField("Enable auto-rotation?") + smart_proxy = BooleanField("Requires smart proxy?") submit = SubmitField('Save Changes') @@ -42,6 +44,7 @@ def origin_new(group_id: Optional[int] = None) -> ResponseReturnValue: origin.domain_name = form.domain_name.data origin.description = form.description.data origin.auto_rotation = form.auto_rotate.data + origin.smart = form.smart_proxy.data origin.created = datetime.utcnow() origin.updated = datetime.utcnow() try: @@ -69,12 +72,14 @@ def origin_edit(origin_id: int) -> ResponseReturnValue: status=404) form = EditOriginForm(group=origin.group_id, description=origin.description, - auto_rotate=origin.auto_rotation) + auto_rotate=origin.auto_rotation, + smart_proxy=origin.smart) form.group.choices = [(x.id, x.group_name) for x in Group.query.all()] if form.validate_on_submit(): origin.group_id = form.group.data origin.description = form.description.data origin.auto_rotation = form.auto_rotate.data + origin.smart = form.smart_proxy.data origin.updated = datetime.utcnow() try: db.session.commit() diff --git a/app/portal/smart_proxy.py b/app/portal/smart_proxy.py new file mode 100644 index 0000000..d744e66 --- /dev/null +++ b/app/portal/smart_proxy.py @@ -0,0 +1,17 @@ +from flask import render_template, Blueprint +from flask.typing import ResponseReturnValue +from sqlalchemy import desc + +from app.models.mirrors import SmartProxy + +bp = Blueprint("smart_proxy", __name__) + + +@bp.route("/list") +def smart_proxy_list() -> ResponseReturnValue: + instances = SmartProxy.query.filter(SmartProxy.destroyed.is_(None)).order_by(desc(SmartProxy.added)).all() + return render_template("list.html.j2", + section="smart_proxy", + title="Smart Proxy Instances", + item="smart proxy", + items=instances) diff --git a/app/portal/templates/base.html.j2 b/app/portal/templates/base.html.j2 index 76c2065..2353040 100644 --- a/app/portal/templates/base.html.j2 +++ b/app/portal/templates/base.html.j2 @@ -132,8 +132,8 @@ diff --git a/app/portal/templates/icons.html.j2 b/app/portal/templates/icons.html.j2 index ceb2b0b..55e5f26 100644 --- a/app/portal/templates/icons.html.j2 +++ b/app/portal/templates/icons.html.j2 @@ -71,6 +71,11 @@ viewBox="0 0 16 16"> + {% elif i == "terminal" %} + + + + {% elif i == "onion" %} diff --git a/app/portal/templates/list.html.j2 b/app/portal/templates/list.html.j2 index bf83409..06ad596 100644 --- a/app/portal/templates/list.html.j2 +++ b/app/portal/templates/list.html.j2 @@ -1,6 +1,6 @@ {% extends "base.html.j2" %} {% from "tables.html.j2" import alarms_table, automations_table, bridgeconfs_table, bridges_table, eotk_table, - groups_table, mirrorlists_table, origins_table, origin_onion_table, onions_table, proxies_table, + groups_table, instances_table, mirrorlists_table, origins_table, origin_onion_table, onions_table, proxies_table, webhook_table %} {% block content %} @@ -35,6 +35,8 @@ {{ origins_table(items) }} {% elif item == "proxy" %} {{ proxies_table(items) }} + {% elif item == "smart proxy" %} + {{ instances_table("smart_proxy", items) }} {% elif item == "webhook" %} {{ webhook_table(items) }} {% endif %} diff --git a/app/portal/templates/tables.html.j2 b/app/portal/templates/tables.html.j2 index 0b0ed78..ad84a60 100644 --- a/app/portal/templates/tables.html.j2 +++ b/app/portal/templates/tables.html.j2 @@ -1,3 +1,5 @@ +{% from "icons.html.j2" import icon %} + {% macro alarm_ok() %} @@ -49,7 +51,7 @@ {% endmacro %} -{% macro eotk_table(instances) %} +{% macro instances_table(application, instances) %}
@@ -92,8 +94,13 @@ {% endfor %} {% endif %} @@ -103,6 +110,10 @@ {% endmacro %} +{% macro eotk_table(instances) %} +{{ instances_table("eotk", instances) }} +{% endmacro %} + {% macro automations_table(automations) %}
- Preview Configuration + {% endif %} + + {{ icon("terminal") }} +
@@ -198,7 +209,8 @@ - + + @@ -215,6 +227,7 @@ +
Name DescriptionAuto-rotationAuto-RotationSmart Proxy Onion Service Group Actions {{ origin.description }} {% if origin.auto_rotation %}✅{% else %}❌{% endif %}{% if origin.smart %}✅{% else %}❌{% endif %} {% if origin.onion() %}✅{% else %}❌{% endif %} {{ origin.group.group_name }} @@ -257,9 +270,9 @@ {{ origin.description }} {% if origin.onion() %} - - {{ origin.onion() }}.onion + {{ origin.onion() }} {% endif %} diff --git a/app/terraform/__init__.py b/app/terraform/__init__.py index fcbb947..37567c8 100644 --- a/app/terraform/__init__.py +++ b/app/terraform/__init__.py @@ -1,6 +1,8 @@ from abc import ABCMeta, abstractmethod import os -from typing import Tuple, Optional +from typing import Tuple, Optional, Any + +import jinja2 from app import app @@ -34,3 +36,16 @@ class BaseAutomation(metaclass=ABCMeta): self.short_name or self.__class__.__name__.lower(), filename or "" ) + + def tmpl_write(self, filename: str, template: str, **kwargs: Any) -> None: + """ + Write a Jinja2 template to the working directory for use by an automation module. + + :param filename: filename to write to + :param template: Jinja2 template + :param kwargs: variables for use with the template + :return: None + """ + tmpl = jinja2.Template(template) + with open(self.working_directory(filename), 'w') as tf: + tf.write(tmpl.render(**kwargs)) diff --git a/app/terraform/proxy/__init__.py b/app/terraform/proxy/__init__.py index 63abc77..9c95d92 100644 --- a/app/terraform/proxy/__init__.py +++ b/app/terraform/proxy/__init__.py @@ -12,10 +12,32 @@ from tldextract import tldextract from app import app from app.extensions import db from app.models.base import Group -from app.models.mirrors import Proxy +from app.models.mirrors import Proxy, Origin, SmartProxy from app.terraform.terraform import TerraformAutomation +def update_smart_proxy_instance(group_id: int, + provider: str, + region: str, + instance_id: str) -> None: + print("SMART PROXY") + instance = SmartProxy.query.filter( + SmartProxy.group_id == group_id, + SmartProxy.region == region, + SmartProxy.provider == provider, + SmartProxy.destroyed.is_(None) + ).first() + if instance is None: + instance = SmartProxy() + instance.added = datetime.datetime.utcnow() + instance.group_id = group_id + instance.provider = provider + instance.region = region + db.session.add(instance) + instance.updated = datetime.datetime.utcnow() + instance.instance_id = instance_id + + class ProxyAutomation(TerraformAutomation): subgroup_max = math.inf """ @@ -35,6 +57,11 @@ class ProxyAutomation(TerraformAutomation): in the templating of the Terraform configuration. """ + smart_proxies = False + """ + Whether this provider supports "smart" proxies. + """ + def get_subgroups(self) -> Dict[int, Dict[int, int]]: conn = db.engine.connect() result = conn.execute(text(""" @@ -118,18 +145,40 @@ class ProxyAutomation(TerraformAutomation): self.import_state(self.tf_show()) def tf_generate(self) -> None: + groups = Group.query.all() self.tf_write( self.template, - groups=Group.query.all(), + groups=groups, proxies=Proxy.query.filter( - Proxy.provider == self.provider, - Proxy.destroyed.is_(None) - ).all(), - subgroups=self.get_subgroups(), - global_namespace=app.config['GLOBAL_NAMESPACE'], - bypass_token=app.config['BYPASS_TOKEN'], - **{ - k: app.config[k.upper()] - for k in self.template_parameters - } - ) + Proxy.provider == self.provider, Proxy.destroyed.is_(None)).all(), subgroups=self.get_subgroups(), + global_namespace=app.config['GLOBAL_NAMESPACE'], bypass_token=app.config['BYPASS_TOKEN'], + **{k: app.config[k.upper()] for k in self.template_parameters}) + if self.smart_proxies: + for group in groups: + self.sp_config(group) + + def sp_config(self, group: Group) -> None: + group_origins: List[Origin] = Origin.query.filter( + Origin.group_id == group.id, + Origin.destroyed.is_(None), + Origin.smart.is_(True) + ).all() + self.tmpl_write(f"smart_proxy.{group.id}.conf", """ + {% for origin in origins %} + server { + listen 443 ssl; + server_name origin-{{ origin.id }}.{{ provider }}.smart.censorship.guide; + location / { + proxy_set_header Accept-Encoding ""; + proxy_ssl_server_name on; + proxy_pass https://{{ origin.domain_name }}/; + subs_filter_types text/html text/css text/xml; + subs_filter https://{{ origin.domain_name }}/ /; + } + ssl_certificate /etc/ssl/smart_proxy.crt; + ssl_certificate_key /etc/ssl/private/smart_proxy.key; + } + {% endfor %} + """, + provider=self.provider, + origins=group_origins) diff --git a/app/terraform/proxy/cloudfront.py b/app/terraform/proxy/cloudfront.py index a6af756..ecd1f47 100644 --- a/app/terraform/proxy/cloudfront.py +++ b/app/terraform/proxy/cloudfront.py @@ -3,34 +3,66 @@ from typing import Any from app.extensions import db from app.models.mirrors import Proxy -from app.terraform.proxy import ProxyAutomation +from app.terraform.proxy import ProxyAutomation, update_smart_proxy_instance class ProxyCloudfrontAutomation(ProxyAutomation): short_name = "proxy_cloudfront" description = "Deploy proxies to AWS CloudFront" provider = "cloudfront" + smart_proxies = True template_parameters = [ "aws_access_key", - "aws_secret_key" + "aws_secret_key", + "rfc2136_nameserver", + "rfc2136_tsig_key", + "rfc2136_tsig_secret", + "smart_zone" ] template = """ terraform { required_providers { + acme = { + source = "vancluever/acme" + version = "~> 2.8.0" + } aws = { version = "~> 4.4.0" } + dns = { + version = "~> 3.2.3" + } } } + provider "acme" { + server_url = "https://acme-v02.api.letsencrypt.org/directory" + } + provider "aws" { access_key = "{{ aws_access_key }}" secret_key = "{{ aws_secret_key }}" region = "us-east-2" } + provider "dns" { + update { + server = local.rfc2136_nameserver + key_name = local.rfc2136_tsig_key + key_secret = local.rfc2136_tsig_secret + key_algorithm = "hmac-sha512" + } + } + + locals { + rfc2136_nameserver = "{{ rfc2136_nameserver }}" + rfc2136_tsig_key = "{{ rfc2136_tsig_key }}" + rfc2136_tsig_secret = "{{ rfc2136_tsig_secret }}" + smart_zone = "{{ smart_zone }}" + } + {% for group in groups %} module "label_{{ group.id }}" { source = "cloudposse/label/null" @@ -55,13 +87,47 @@ class ProxyCloudfrontAutomation(ProxyAutomation): resource "aws_sns_topic" "alarms_{{ group.id }}" { name = "${module.label_{{ group.id }}.id}-cloudfront-alarms" } + + {% for origin in group.origins | selectattr("destroyed", "none") | selectattr("smart") %} + {% if loop.first %} + module "smart_proxy_{{ group.id }}" { + source = "sr2c/bc-smart-proxy-instance/aws" + version = "0.0.1" + context = module.label_{{ group.id }}.context + name = "smart-proxy" + disable_api_termination = false + domain_name = "cloudfront.smart.${local.smart_zone}" + rfc2136_nameserver = local.rfc2136_nameserver + rfc2136_tsig_key = local.rfc2136_tsig_key + rfc2136_tsig_secret = local.rfc2136_tsig_secret + } + + resource "aws_s3_object" "smart_config_{{ group.id }}" { + bucket = module.smart_proxy_{{ group.id }}.config_bucket_name + key = "default" + source = "smart_proxy.{{ group.id }}.conf" + etag = filemd5("smart_proxy.{{ group.id }}.conf") + } + {% endif %} + + resource "dns_a_record_set" "smart_dns_{{ origin.id }}" { + zone = "{{ smart_zone }}" + name = "origin-{{ origin.id }}.cloudfront.smart" + addresses = module.smart_proxy_{{ origin.group.id }}.ip_addresses + ttl = 60 + } + {% endfor %} {% endfor %} {% for proxy in proxies %} module "cloudfront_{{ proxy.id }}" { source = "sr2c/bc-proxy/aws" version = "0.0.7" + {% if proxy.origin.smart %} + origin_domain = "origin-{{ proxy.origin.id }}.cloudfront.smart.{{ smart_zone[:-1] }}" + {% else %} origin_domain = "{{ proxy.origin.domain_name }}" + {% endif %} logging_bucket = module.log_bucket_{{ proxy.origin.group.id }}.bucket_domain_name sns_topic_arn = aws_sns_topic.alarms_{{ proxy.origin.group.id }}.arn low_bandwidth_alarm = false @@ -88,4 +154,12 @@ class ProxyCloudfrontAutomation(ProxyAutomation): proxy.slug = res['values']['id'] proxy.terraform_updated = datetime.datetime.utcnow() break + for g in state["values"]["root_module"]["child_modules"]: + if g["address"].startswith("module.smart_proxy_"): + group_id = int(g["address"][len("module.smart_proxy_"):]) + for s in g["child_modules"]: + if s["address"].endswith(".module.instance"): + for x in s["resources"]: + if x["address"].endswith(".module.instance.aws_instance.default[0]"): + update_smart_proxy_instance(group_id, self.provider, "us-east-2", x['values']['id']) db.session.commit() diff --git a/app/terraform/terraform.py b/app/terraform/terraform.py index e58bbfb..feda6c0 100644 --- a/app/terraform/terraform.py +++ b/app/terraform/terraform.py @@ -3,8 +3,6 @@ import subprocess # nosec from abc import abstractmethod from typing import Any, Optional, Tuple -import jinja2 - from app.terraform import BaseAutomation @@ -151,6 +149,4 @@ class TerraformAutomation(BaseAutomation): return json.loads(terraform.stdout) def tf_write(self, template: str, **kwargs: Any) -> None: - tmpl = jinja2.Template(template) - with open(self.working_directory("main.tf"), 'w') as tf: - tf.write(tmpl.render(**kwargs)) + self.tmpl_write("main.tf", template, **kwargs) diff --git a/migrations/versions/133961a48525_add_smart_proxies.py b/migrations/versions/133961a48525_add_smart_proxies.py new file mode 100644 index 0000000..b9c1abd --- /dev/null +++ b/migrations/versions/133961a48525_add_smart_proxies.py @@ -0,0 +1,50 @@ +"""add smart proxies + +Revision ID: 133961a48525 +Revises: 31aec2f86c40 +Create Date: 2022-05-24 14:56:43.071054 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '133961a48525' +down_revision = '31aec2f86c40' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('smart_proxy', + 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('group_id', sa.Integer(), nullable=False), + sa.Column('instance_id', sa.String(length=100), nullable=True), + sa.Column('provider', sa.String(length=20), nullable=False), + sa.Column('region', sa.String(length=20), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], name=op.f('fk_smart_proxy_group_id_group')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_smart_proxy')) + ) + with op.batch_alter_table('origin', schema=None) as batch_op: + batch_op.add_column(sa.Column('smart', sa.Boolean(), nullable=True)) + with op.batch_alter_table('origin', schema=None) as batch_op: + batch_op.execute("UPDATE origin SET smart=FALSE") + batch_op.alter_column(sa.Column('smart', sa.Boolean(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('origin', schema=None) as batch_op: + batch_op.drop_column('smart') + + op.drop_table('smart_proxy') + # ### end Alembic commands ###