feat(bridges): next generation bridge management
This commit is contained in:
parent
20fad30a06
commit
05285a4ae6
12 changed files with 329 additions and 89 deletions
|
@ -19,6 +19,7 @@ from app.terraform.block.bridge_roskomsvoboda import BlockBridgeRoskomsvobodaAut
|
||||||
from app.terraform.block_external import BlockExternalAutomation
|
from app.terraform.block_external import BlockExternalAutomation
|
||||||
from app.terraform.block_ooni import BlockOONIAutomation
|
from app.terraform.block_ooni import BlockOONIAutomation
|
||||||
from app.terraform.block_roskomsvoboda import BlockRoskomsvobodaAutomation
|
from app.terraform.block_roskomsvoboda import BlockRoskomsvobodaAutomation
|
||||||
|
from app.terraform.bridge.meta import BridgeMetaAutomation
|
||||||
from app.terraform.eotk.aws import EotkAWSAutomation
|
from app.terraform.eotk.aws import EotkAWSAutomation
|
||||||
from app.terraform.alarms.eotk_aws import AlarmEotkAwsAutomation
|
from app.terraform.alarms.eotk_aws import AlarmEotkAwsAutomation
|
||||||
from app.terraform.alarms.proxy_azure_cdn import AlarmProxyAzureCdnAutomation
|
from app.terraform.alarms.proxy_azure_cdn import AlarmProxyAzureCdnAutomation
|
||||||
|
@ -56,6 +57,7 @@ jobs = {
|
||||||
BridgeAWSAutomation,
|
BridgeAWSAutomation,
|
||||||
BridgeGandiAutomation,
|
BridgeGandiAutomation,
|
||||||
BridgeHcloudAutomation,
|
BridgeHcloudAutomation,
|
||||||
|
BridgeMetaAutomation,
|
||||||
BridgeOvhAutomation,
|
BridgeOvhAutomation,
|
||||||
EotkAWSAutomation,
|
EotkAWSAutomation,
|
||||||
ListGithubAutomation,
|
ListGithubAutomation,
|
||||||
|
|
|
@ -11,7 +11,6 @@ class Group(AbstractConfiguration):
|
||||||
eotk = db.Column(db.Boolean())
|
eotk = db.Column(db.Boolean())
|
||||||
|
|
||||||
origins = db.relationship("Origin", back_populates="group")
|
origins = db.relationship("Origin", back_populates="group")
|
||||||
bridgeconfs = db.relationship("BridgeConf", back_populates="group")
|
|
||||||
eotks = db.relationship("Eotk", back_populates="group")
|
eotks = db.relationship("Eotk", back_populates="group")
|
||||||
onions = db.relationship("Onion", back_populates="group")
|
onions = db.relationship("Onion", back_populates="group")
|
||||||
smart_proxies = db.relationship("SmartProxy", back_populates="group")
|
smart_proxies = db.relationship("SmartProxy", back_populates="group")
|
||||||
|
@ -39,16 +38,17 @@ class Pool(AbstractConfiguration):
|
||||||
api_key = db.Column(db.String(80), nullable=False)
|
api_key = db.Column(db.String(80), nullable=False)
|
||||||
redirector_domain = db.Column(db.String(128), nullable=True)
|
redirector_domain = db.Column(db.String(128), nullable=True)
|
||||||
|
|
||||||
|
bridgeconfs = db.relationship("BridgeConf", back_populates="pool")
|
||||||
|
proxies = db.relationship("Proxy", back_populates="pool")
|
||||||
|
lists = db.relationship("MirrorList", back_populates="pool")
|
||||||
|
groups = db.relationship("Group", secondary="pool_group", back_populates="pools")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def csv_header(cls) -> List[str]:
|
def csv_header(cls) -> List[str]:
|
||||||
return super().csv_header() + [
|
return super().csv_header() + [
|
||||||
"pool_name"
|
"pool_name"
|
||||||
]
|
]
|
||||||
|
|
||||||
proxies = db.relationship("Proxy", back_populates="pool")
|
|
||||||
lists = db.relationship("MirrorList", back_populates="pool")
|
|
||||||
groups = db.relationship("Group", secondary="pool_group", back_populates="pools")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def brn(self) -> BRN:
|
def brn(self) -> BRN:
|
||||||
return BRN(
|
return BRN(
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
@ -6,13 +7,20 @@ from app.extensions import db
|
||||||
from app.models import AbstractConfiguration, AbstractResource
|
from app.models import AbstractConfiguration, AbstractResource
|
||||||
|
|
||||||
|
|
||||||
class BridgeConf(AbstractConfiguration):
|
class ProviderAllocation(enum.Enum):
|
||||||
group_id = db.Column(db.Integer, db.ForeignKey("group.id"), nullable=False)
|
RANDOM = "random"
|
||||||
provider = db.Column(db.String(20), nullable=False)
|
COST = "cost"
|
||||||
method = db.Column(db.String(20), nullable=False)
|
|
||||||
number = db.Column(db.Integer())
|
|
||||||
|
|
||||||
group = db.relationship("Group", back_populates="bridgeconfs")
|
|
||||||
|
class BridgeConf(AbstractConfiguration):
|
||||||
|
pool_id = db.Column(db.Integer, db.ForeignKey("pool.id"), nullable=False)
|
||||||
|
method = db.Column(db.String(20), nullable=False)
|
||||||
|
target_number = db.Column(db.Integer())
|
||||||
|
max_number = db.Column(db.Integer())
|
||||||
|
expiry_hours = db.Column(db.Integer())
|
||||||
|
provider_allocation = db.Column(db.Enum(ProviderAllocation))
|
||||||
|
|
||||||
|
pool = db.relationship("Pool", back_populates="bridgeconfs")
|
||||||
bridges = db.relationship("Bridge", back_populates="conf")
|
bridges = db.relationship("Bridge", back_populates="conf")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -36,12 +44,13 @@ class BridgeConf(AbstractConfiguration):
|
||||||
@classmethod
|
@classmethod
|
||||||
def csv_header(cls) -> List[str]:
|
def csv_header(cls) -> List[str]:
|
||||||
return super().csv_header() + [
|
return super().csv_header() + [
|
||||||
"group_id", "provider", "method", "description", "number"
|
"pool_id", "provider", "method", "description", "target_number", "max_number", "expiry_hours"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class Bridge(AbstractResource):
|
class Bridge(AbstractResource):
|
||||||
conf_id = db.Column(db.Integer, db.ForeignKey("bridge_conf.id"), nullable=False)
|
conf_id = db.Column(db.Integer, db.ForeignKey("bridge_conf.id"), nullable=False)
|
||||||
|
provider = db.Column(db.String(), nullable=False)
|
||||||
terraform_updated = db.Column(db.DateTime(), nullable=True)
|
terraform_updated = db.Column(db.DateTime(), nullable=True)
|
||||||
nickname = db.Column(db.String(255), nullable=True)
|
nickname = db.Column(db.String(255), nullable=True)
|
||||||
fingerprint = db.Column(db.String(255), nullable=True)
|
fingerprint = db.Column(db.String(255), nullable=True)
|
||||||
|
@ -53,9 +62,9 @@ class Bridge(AbstractResource):
|
||||||
@property
|
@property
|
||||||
def brn(self) -> BRN:
|
def brn(self) -> BRN:
|
||||||
return BRN(
|
return BRN(
|
||||||
group_id=self.conf.group_id,
|
group_id=0,
|
||||||
product="bridge",
|
product="bridge",
|
||||||
provider=self.conf.provider,
|
provider=self.provider,
|
||||||
resource_type="bridge",
|
resource_type="bridge",
|
||||||
resource_id=str(self.id)
|
resource_id=str(self.id)
|
||||||
)
|
)
|
||||||
|
|
|
@ -44,6 +44,15 @@ portal.register_blueprint(storage, url_prefix="/state")
|
||||||
portal.register_blueprint(webhook, url_prefix="/webhook")
|
portal.register_blueprint(webhook, url_prefix="/webhook")
|
||||||
|
|
||||||
|
|
||||||
|
@portal.app_template_filter("bridge_expiry")
|
||||||
|
def calculate_bridge_expiry(b: Bridge) -> str:
|
||||||
|
expiry = b.deprecated + timedelta(hours=b.conf.expiry_hours)
|
||||||
|
countdown = expiry - datetime.utcnow()
|
||||||
|
if countdown.days == 0:
|
||||||
|
return f"{countdown.seconds // 3600} hours"
|
||||||
|
return f"{countdown.days} days"
|
||||||
|
|
||||||
|
|
||||||
@portal.app_template_filter("mirror_expiry")
|
@portal.app_template_filter("mirror_expiry")
|
||||||
def calculate_mirror_expiry(s: datetime) -> str:
|
def calculate_mirror_expiry(s: datetime) -> str:
|
||||||
expiry = s + timedelta(days=3)
|
expiry = s + timedelta(days=3)
|
||||||
|
|
|
@ -9,7 +9,6 @@ from app.portal.util import LifecycleForm
|
||||||
|
|
||||||
bp = Blueprint("bridge", __name__)
|
bp = Blueprint("bridge", __name__)
|
||||||
|
|
||||||
|
|
||||||
_SECTION_TEMPLATE_VARS = {
|
_SECTION_TEMPLATE_VARS = {
|
||||||
"section": "bridge",
|
"section": "bridge",
|
||||||
"help_url": "https://bypass.censorship.guide/user/bridges.html"
|
"help_url": "https://bypass.censorship.guide/user/bridges.html"
|
||||||
|
@ -45,3 +44,24 @@ def bridge_blocked(bridge_id: int) -> ResponseReturnValue:
|
||||||
message=bridge.hashed_fingerprint,
|
message=bridge.hashed_fingerprint,
|
||||||
form=form,
|
form=form,
|
||||||
**_SECTION_TEMPLATE_VARS)
|
**_SECTION_TEMPLATE_VARS)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/expire/<bridge_id>", methods=['GET', 'POST'])
|
||||||
|
def bridge_expire(bridge_id: int) -> ResponseReturnValue:
|
||||||
|
bridge: Optional[Bridge] = Bridge.query.filter(Bridge.id == bridge_id, Bridge.destroyed.is_(None)).first()
|
||||||
|
if bridge is None:
|
||||||
|
return Response(render_template("error.html.j2",
|
||||||
|
header="404 Proxy Not Found",
|
||||||
|
message="The requested bridge could not be found.",
|
||||||
|
**_SECTION_TEMPLATE_VARS))
|
||||||
|
form = LifecycleForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
bridge.destroy()
|
||||||
|
db.session.commit()
|
||||||
|
flash("Bridge will be shortly destroyed.", "success")
|
||||||
|
return redirect(url_for("portal.bridgeconf.bridgeconf_edit", bridgeconf_id=bridge.conf_id))
|
||||||
|
return render_template("lifecycle.html.j2",
|
||||||
|
header=f"Destroy bridge {bridge.hashed_fingerprint}?",
|
||||||
|
message=bridge.hashed_fingerprint,
|
||||||
|
form=form,
|
||||||
|
**_SECTION_TEMPLATE_VARS)
|
||||||
|
|
|
@ -9,7 +9,7 @@ from wtforms import SelectField, StringField, IntegerField, SubmitField
|
||||||
from wtforms.validators import DataRequired, NumberRange
|
from wtforms.validators import DataRequired, NumberRange
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.base import Group
|
from app.models.base import Pool
|
||||||
from app.models.bridges import BridgeConf
|
from app.models.bridges import BridgeConf
|
||||||
from app.portal.util import response_404, view_lifecycle
|
from app.portal.util import response_404, view_lifecycle
|
||||||
|
|
||||||
|
@ -23,17 +23,34 @@ _SECTION_TEMPLATE_VARS = {
|
||||||
|
|
||||||
|
|
||||||
class NewBridgeConfForm(FlaskForm): # type: ignore
|
class NewBridgeConfForm(FlaskForm): # type: ignore
|
||||||
provider = SelectField('Provider', validators=[DataRequired()])
|
|
||||||
method = SelectField('Distribution Method', validators=[DataRequired()])
|
method = SelectField('Distribution Method', validators=[DataRequired()])
|
||||||
description = StringField('Description')
|
description = StringField('Description')
|
||||||
group = SelectField('Group', validators=[DataRequired()])
|
pool = SelectField('Pool', validators=[DataRequired()])
|
||||||
number = IntegerField('Number', validators=[NumberRange(1, message="One or more bridges must be created")])
|
target_number = IntegerField('Target Number',
|
||||||
|
description="The number of active bridges to deploy (excluding deprecated bridges).",
|
||||||
|
validators=[NumberRange(1, message="One or more bridges must be created.")])
|
||||||
|
max_number = IntegerField('Maximum Number',
|
||||||
|
description="The maximum number of bridges to deploy (including deprecated bridges).",
|
||||||
|
validators=[
|
||||||
|
NumberRange(1, message="Must be at least 1, ideally greater than target number.")])
|
||||||
|
expiry_hours = IntegerField('Expiry Timer (hours)',
|
||||||
|
description=("The number of hours to wait after a bridge is deprecated before its "
|
||||||
|
"destruction."))
|
||||||
submit = SubmitField('Save Changes')
|
submit = SubmitField('Save Changes')
|
||||||
|
|
||||||
|
|
||||||
class EditBridgeConfForm(FlaskForm): # type: ignore
|
class EditBridgeConfForm(FlaskForm): # type: ignore
|
||||||
description = StringField('Description')
|
description = StringField('Description')
|
||||||
number = IntegerField('Number', validators=[NumberRange(1, message="One or more bridges must be created")])
|
target_number = IntegerField('Target Number',
|
||||||
|
description="The number of active bridges to deploy (excluding deprecated bridges).",
|
||||||
|
validators=[NumberRange(1, message="One or more bridges must be created.")])
|
||||||
|
max_number = IntegerField('Maximum Number',
|
||||||
|
description="The maximum number of bridges to deploy (including deprecated bridges).",
|
||||||
|
validators=[
|
||||||
|
NumberRange(1, message="Must be at least 1, ideally greater than target number.")])
|
||||||
|
expiry_hours = IntegerField('Expiry Timer (hours)',
|
||||||
|
description=("The number of hours to wait after a bridge is deprecated before its "
|
||||||
|
"destruction."))
|
||||||
submit = SubmitField('Save Changes')
|
submit = SubmitField('Save Changes')
|
||||||
|
|
||||||
|
|
||||||
|
@ -52,13 +69,7 @@ def bridgeconf_list() -> ResponseReturnValue:
|
||||||
@bp.route("/new/<group_id>", methods=['GET', 'POST'])
|
@bp.route("/new/<group_id>", methods=['GET', 'POST'])
|
||||||
def bridgeconf_new(group_id: Optional[int] = None) -> ResponseReturnValue:
|
def bridgeconf_new(group_id: Optional[int] = None) -> ResponseReturnValue:
|
||||||
form = NewBridgeConfForm()
|
form = NewBridgeConfForm()
|
||||||
form.group.choices = [(x.id, x.group_name) for x in Group.query.all()]
|
form.pool.choices = [(x.id, x.pool_name) for x in Pool.query.filter(Pool.destroyed.is_(None)).all()]
|
||||||
form.provider.choices = [
|
|
||||||
("aws", "AWS Lightsail"),
|
|
||||||
("hcloud", "Hetzner Cloud"),
|
|
||||||
("ovh", "OVH Public Cloud"),
|
|
||||||
("gandi", "GandiCloud VPS")
|
|
||||||
]
|
|
||||||
form.method.choices = [
|
form.method.choices = [
|
||||||
("any", "Any (BridgeDB)"),
|
("any", "Any (BridgeDB)"),
|
||||||
("email", "E-Mail (BridgeDB)"),
|
("email", "E-Mail (BridgeDB)"),
|
||||||
|
@ -67,18 +78,19 @@ def bridgeconf_new(group_id: Optional[int] = None) -> ResponseReturnValue:
|
||||||
("none", "None (Private)")
|
("none", "None (Private)")
|
||||||
]
|
]
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
bridge_conf = BridgeConf()
|
bridgeconf = BridgeConf()
|
||||||
bridge_conf.group_id = form.group.data
|
bridgeconf.pool_id = form.pool.data
|
||||||
bridge_conf.provider = form.provider.data
|
bridgeconf.method = form.method.data
|
||||||
bridge_conf.method = form.method.data
|
bridgeconf.description = form.description.data
|
||||||
bridge_conf.description = form.description.data
|
bridgeconf.target_number = form.target_number.data
|
||||||
bridge_conf.number = form.number.data
|
bridgeconf.max_number = form.max_number.data
|
||||||
bridge_conf.created = datetime.utcnow()
|
bridgeconf.expiry_hours = form.expiry_hours.data
|
||||||
bridge_conf.updated = datetime.utcnow()
|
bridgeconf.created = datetime.utcnow()
|
||||||
|
bridgeconf.updated = datetime.utcnow()
|
||||||
try:
|
try:
|
||||||
db.session.add(bridge_conf)
|
db.session.add(bridgeconf)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"Created new bridge configuration {bridge_conf.id}.", "success")
|
flash(f"Created new bridge configuration {bridgeconf.id}.", "success")
|
||||||
return redirect(url_for("portal.bridgeconf.bridgeconf_list"))
|
return redirect(url_for("portal.bridgeconf.bridgeconf_list"))
|
||||||
except exc.SQLAlchemyError:
|
except exc.SQLAlchemyError:
|
||||||
flash("Failed to create new bridge configuration.", "danger")
|
flash("Failed to create new bridge configuration.", "danger")
|
||||||
|
@ -100,10 +112,15 @@ def bridgeconf_edit(bridgeconf_id: int) -> ResponseReturnValue:
|
||||||
**_SECTION_TEMPLATE_VARS),
|
**_SECTION_TEMPLATE_VARS),
|
||||||
status=404)
|
status=404)
|
||||||
form = EditBridgeConfForm(description=bridgeconf.description,
|
form = EditBridgeConfForm(description=bridgeconf.description,
|
||||||
number=bridgeconf.number)
|
target_number=bridgeconf.target_number,
|
||||||
|
max_number=bridgeconf.max_number,
|
||||||
|
expiry_hours=bridgeconf.expiry_hours,
|
||||||
|
)
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
bridgeconf.description = form.description.data
|
bridgeconf.description = form.description.data
|
||||||
bridgeconf.number = form.number.data
|
bridgeconf.target_number = form.target_number.data
|
||||||
|
bridgeconf.max_number = form.max_number.data
|
||||||
|
bridgeconf.expiry_hours = form.expiry_hours.data
|
||||||
bridgeconf.updated = datetime.utcnow()
|
bridgeconf.updated = datetime.utcnow()
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="h2 mt-3">Tor Bridge Configuration</h1>
|
<h1 class="h2 mt-3">Tor Bridge Configuration</h1>
|
||||||
<h2 class="h3">{{ bridgeconf.group.group_name }}: {{ bridgeconf.provider }}/{{ bridgeconf.method }}</h2>
|
<h2 class="h3">{{ bridgeconf.pool.pool_name }}: {{ bridgeconf.method }}</h2>
|
||||||
|
|
||||||
<div style="border: 1px solid #666;" class="p-3">
|
<div style="border: 1px solid #666;" class="p-3">
|
||||||
{{ render_form(form) }}
|
{{ render_form(form) }}
|
||||||
|
|
|
@ -453,10 +453,11 @@
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Group</th>
|
<th scope="col">Pool</th>
|
||||||
<th scope="col">Provider</th>
|
|
||||||
<th scope="col">Distribution Method</th>
|
<th scope="col">Distribution Method</th>
|
||||||
<th scope="col">Number</th>
|
<th scope="col">Target Number</th>
|
||||||
|
<th scope="col">Max Number</th>
|
||||||
|
<th scope="col">Expiry Timer</th>
|
||||||
<th scope="col">Actions</th>
|
<th scope="col">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -465,11 +466,12 @@
|
||||||
{% if not bridgeconf.destroyed %}
|
{% if not bridgeconf.destroyed %}
|
||||||
<tr class="align-middle">
|
<tr class="align-middle">
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for("portal.group.group_edit", group_id=bridgeconf.group.id) }}">{{ bridgeconf.group.group_name }}</a>
|
<a href="{{ url_for("portal.pool.pool_edit", pool_id=bridgeconf.pool.id) }}" title="{{ bridgeconf.pool.description }}">{{ bridgeconf.pool.pool_name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ bridgeconf.provider }}</td>
|
|
||||||
<td>{{ bridgeconf.method }}</td>
|
<td>{{ bridgeconf.method }}</td>
|
||||||
<td>{{ bridgeconf.number }}</td>
|
<td>{{ bridgeconf.target_number }}</td>
|
||||||
|
<td>{{ bridgeconf.max_number }}</td>
|
||||||
|
<td>{{ bridgeconf.expiry_hours }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for("portal.bridgeconf.bridgeconf_edit", bridgeconf_id=bridgeconf.id) }}"
|
<a href="{{ url_for("portal.bridgeconf.bridgeconf_edit", bridgeconf_id=bridgeconf.id) }}"
|
||||||
class="btn btn-primary btn-sm">View/Edit</a>
|
class="btn btn-primary btn-sm">View/Edit</a>
|
||||||
|
@ -489,7 +491,7 @@
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Group</th>
|
<th scope="col">Pool</th>
|
||||||
<th scope="col">Configuration</th>
|
<th scope="col">Configuration</th>
|
||||||
<th scope="col">Nickname</th>
|
<th scope="col">Nickname</th>
|
||||||
<th scope="col">Hashed Fingerprint</th>
|
<th scope="col">Hashed Fingerprint</th>
|
||||||
|
@ -502,9 +504,9 @@
|
||||||
{% if not bridge.destroyed %}
|
{% if not bridge.destroyed %}
|
||||||
<tr class="align-middle{% if bridge.deprecated %} bg-warning{% endif %}">
|
<tr class="align-middle{% if bridge.deprecated %} bg-warning{% endif %}">
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for("portal.group.group_edit", group_id=bridge.conf.group.id) }}">{{ bridge.conf.group.group_name }}</a>
|
<a href="{{ url_for("portal.pool.pool_edit", pool_id=bridge.conf.pool_id) }}">{{ bridge.conf.pool.pool_name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ bridge.conf.description }} ({{ bridge.conf.provider }}/{{ bridge.conf.method }})</td>
|
<td>{{ bridge.conf.description }} ({{ bridge.provider }}/{{ bridge.conf.method }})</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://metrics.torproject.org/rs.html#details/{{ bridge.hashed_fingerprint }}">
|
<a href="https://metrics.torproject.org/rs.html#details/{{ bridge.hashed_fingerprint }}">
|
||||||
{{ bridge.nickname }}
|
{{ bridge.nickname }}
|
||||||
|
@ -541,7 +543,9 @@
|
||||||
<td>
|
<td>
|
||||||
{% if bridge.deprecated %}
|
{% if bridge.deprecated %}
|
||||||
<a href="#" class="disabled btn btn-sm btn-outline-dark">Expiring
|
<a href="#" class="disabled btn btn-sm btn-outline-dark">Expiring
|
||||||
in {{ bridge.deprecated | mirror_expiry }}</a>
|
in {{ bridge | bridge_expiry }}</a>
|
||||||
|
<a href="{{ url_for("portal.bridge.bridge_expire", bridge_id=bridge.id) }}"
|
||||||
|
class="btn btn-danger btn-sm">Expire</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("portal.bridge.bridge_blocked", bridge_id=bridge.id) }}"
|
<a href="{{ url_for("portal.bridge.bridge_blocked", bridge_id=bridge.id) }}"
|
||||||
class="btn btn-warning btn-sm">Mark blocked</a>
|
class="btn btn-warning btn-sm">Mark blocked</a>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
from typing import Iterable, Optional, Any, List
|
import sys
|
||||||
|
from typing import Optional, Any, List
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
@ -21,45 +22,12 @@ class BridgeAutomation(TerraformAutomation):
|
||||||
in the templating of the Terraform configuration.
|
in the templating of the Terraform configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def create_missing(self) -> None:
|
max_bridges = sys.maxsize
|
||||||
bridgeconfs: Iterable[BridgeConf] = BridgeConf.query.filter(
|
|
||||||
BridgeConf.provider == self.provider,
|
|
||||||
BridgeConf.destroyed.is_(None)
|
|
||||||
).all()
|
|
||||||
for bridgeconf in bridgeconfs:
|
|
||||||
active_bridges = Bridge.query.filter(
|
|
||||||
Bridge.conf_id == bridgeconf.id,
|
|
||||||
Bridge.deprecated.is_(None)
|
|
||||||
).all()
|
|
||||||
if len(active_bridges) < bridgeconf.number:
|
|
||||||
for _idx in range(bridgeconf.number - len(active_bridges)):
|
|
||||||
bridge = Bridge()
|
|
||||||
bridge.conf_id = bridgeconf.id
|
|
||||||
bridge.added = datetime.datetime.utcnow()
|
|
||||||
bridge.updated = datetime.datetime.utcnow()
|
|
||||||
db.session.add(bridge)
|
|
||||||
elif len(active_bridges) > bridgeconf.number:
|
|
||||||
active_bridge_count = len(active_bridges)
|
|
||||||
for bridge in active_bridges:
|
|
||||||
bridge.deprecate(reason="redundant")
|
|
||||||
active_bridge_count -= 1
|
|
||||||
if active_bridge_count == bridgeconf.number:
|
|
||||||
break
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def destroy_expired(self) -> None:
|
# TODO: Only enable providers that have details configured
|
||||||
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=0)
|
enabled = True
|
||||||
bridges = [b for b in Bridge.query.filter(
|
|
||||||
Bridge.destroyed.is_(None),
|
|
||||||
Bridge.deprecated < cutoff
|
|
||||||
).all() if b.conf.provider == self.provider]
|
|
||||||
for bridge in bridges:
|
|
||||||
bridge.destroy()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def tf_prehook(self) -> Optional[Any]: # pylint: disable=useless-return
|
def tf_prehook(self) -> Optional[Any]: # pylint: disable=useless-return
|
||||||
self.create_missing()
|
|
||||||
self.destroy_expired()
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def tf_generate(self) -> None:
|
def tf_generate(self) -> None:
|
||||||
|
@ -103,3 +71,11 @@ class BridgeAutomation(TerraformAutomation):
|
||||||
bridge.bridgeline = " ".join(parts)
|
bridge.bridgeline = " ".join(parts)
|
||||||
bridge.terraform_updated = datetime.datetime.utcnow()
|
bridge.terraform_updated = datetime.datetime.utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def active_bridges_count(self) -> int:
|
||||||
|
active_bridges = Bridge.query.filter(
|
||||||
|
Bridge.provider == self.provider,
|
||||||
|
Bridge.destroyed.is_(None),
|
||||||
|
).all()
|
||||||
|
return len(active_bridges)
|
||||||
|
|
122
app/terraform/bridge/meta.py
Normal file
122
app/terraform/bridge/meta.py
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from typing import Tuple, List
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models.bridges import BridgeConf, Bridge
|
||||||
|
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 [
|
||||||
|
# In order of cost
|
||||||
|
BridgeHcloudAutomation,
|
||||||
|
BridgeGandiAutomation,
|
||||||
|
BridgeOvhAutomation,
|
||||||
|
# BridgeAWSAutomation, TODO: This module is broken right now
|
||||||
|
] if p.enabled}
|
||||||
|
|
||||||
|
|
||||||
|
def create_bridges(bridgeconf: BridgeConf, count: int) -> int:
|
||||||
|
"""
|
||||||
|
Creates a bridge resource for the given bridge configuration.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logging.debug("No provider has available quota to create missing bridge for configuration %s",
|
||||||
|
bridgeconf.id)
|
||||||
|
logging.debug("Created %s bridges", created)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def deprecate_bridges(bridgeconf: BridgeConf, count: int, reason: str = "redundant") -> int:
|
||||||
|
logging.debug("Deprecating %s bridges (%s) for configuration %s", count, reason, bridgeconf.id)
|
||||||
|
deprecated = 0
|
||||||
|
active_conf_bridges = iter(Bridge.query.filter(
|
||||||
|
Bridge.conf_id == bridgeconf.id,
|
||||||
|
Bridge.deprecated.is_(None),
|
||||||
|
Bridge.destroyed.is_(None),
|
||||||
|
).all())
|
||||||
|
while deprecated < count:
|
||||||
|
logging.debug("Deprecating bridge %s for configuration %s", deprecated + 1, bridgeconf.id)
|
||||||
|
bridge = next(active_conf_bridges)
|
||||||
|
logging.debug("Bridge %r", bridge)
|
||||||
|
bridge.deprecate(reason=reason)
|
||||||
|
deprecated += 1
|
||||||
|
return deprecated
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeMetaAutomation(BaseAutomation):
|
||||||
|
short_name = "bridge_meta"
|
||||||
|
description = "Housekeeping for bridges"
|
||||||
|
frequency = 1
|
||||||
|
|
||||||
|
def automate(self, full: bool = False) -> Tuple[bool, str]:
|
||||||
|
# Destroy expired bridges
|
||||||
|
deprecated_bridges: List[Bridge] = Bridge.query.filter(
|
||||||
|
Bridge.destroyed.is_(None),
|
||||||
|
Bridge.deprecated.is_not(None),
|
||||||
|
).all()
|
||||||
|
logging.debug("Found %s deprecated bridges", len(deprecated_bridges))
|
||||||
|
for bridge in deprecated_bridges:
|
||||||
|
cutoff = datetime.datetime.utcnow() - datetime.timedelta(hours=bridge.conf.expiry_hours)
|
||||||
|
if bridge.deprecated < cutoff:
|
||||||
|
logging.debug("Destroying expired bridge")
|
||||||
|
bridge.destroy()
|
||||||
|
# Deprecate orphaned bridges
|
||||||
|
active_bridges = Bridge.query.filter(
|
||||||
|
Bridge.deprecated.is_(None),
|
||||||
|
Bridge.destroyed.is_(None),
|
||||||
|
).all()
|
||||||
|
logging.debug("Found %s active bridges", len(active_bridges))
|
||||||
|
for bridge in active_bridges:
|
||||||
|
if bridge.conf.destroyed is not None:
|
||||||
|
bridge.deprecate(reason="conf_destroyed")
|
||||||
|
# Create new bridges
|
||||||
|
activate_bridgeconfs = BridgeConf.query.filter(
|
||||||
|
BridgeConf.destroyed.is_(None),
|
||||||
|
).all()
|
||||||
|
logging.debug("Found %s active bridge configurations", len(activate_bridgeconfs))
|
||||||
|
for bridgeconf in activate_bridgeconfs:
|
||||||
|
active_conf_bridges = Bridge.query.filter(
|
||||||
|
Bridge.conf_id == bridgeconf.id,
|
||||||
|
Bridge.deprecated.is_(None),
|
||||||
|
Bridge.destroyed.is_(None),
|
||||||
|
).all()
|
||||||
|
total_conf_bridges = Bridge.query.filter(
|
||||||
|
Bridge.conf_id == bridgeconf.id,
|
||||||
|
Bridge.destroyed.is_(None),
|
||||||
|
).all()
|
||||||
|
logging.debug("Generating new bridges for %s (active: %s, total: %s, target: %s, max: %s)",
|
||||||
|
bridgeconf.id,
|
||||||
|
len(active_conf_bridges),
|
||||||
|
len(total_conf_bridges),
|
||||||
|
bridgeconf.target_number,
|
||||||
|
bridgeconf.max_number
|
||||||
|
)
|
||||||
|
missing = min(
|
||||||
|
bridgeconf.target_number - len(active_conf_bridges),
|
||||||
|
bridgeconf.max_number - len(total_conf_bridges))
|
||||||
|
if missing > 0:
|
||||||
|
create_bridges(bridgeconf, missing)
|
||||||
|
elif missing < 0:
|
||||||
|
deprecate_bridges(bridgeconf, 0 - missing)
|
||||||
|
db.session.commit()
|
||||||
|
return True, ""
|
|
@ -23,6 +23,19 @@ PROXY_PROVIDERS = {p.provider: p for p in [ # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
def create_proxy(pool: Pool, origin: Origin) -> bool:
|
def create_proxy(pool: Pool, origin: Origin) -> bool:
|
||||||
|
"""
|
||||||
|
Creates a web proxy resource for the given origin and pool combination.
|
||||||
|
|
||||||
|
Initially it will attempt to create smart proxies on providers that support smart proxies,
|
||||||
|
and "simple" proxies on other providers. If other providers have exhausted their quota
|
||||||
|
already then a "simple" proxy may be created on a platform that supports smart proxies.
|
||||||
|
|
||||||
|
A boolean is returned to indicate whether a proxy resource was created.
|
||||||
|
|
||||||
|
:param pool: pool to create the resource for
|
||||||
|
:param origin: origin to create the resource for
|
||||||
|
:return: whether a proxy resource was created
|
||||||
|
"""
|
||||||
for desperate in [False, True]:
|
for desperate in [False, True]:
|
||||||
for provider in PROXY_PROVIDERS.values():
|
for provider in PROXY_PROVIDERS.values():
|
||||||
if origin.smart and not provider.smart_proxies: # type: ignore[attr-defined]
|
if origin.smart and not provider.smart_proxies: # type: ignore[attr-defined]
|
||||||
|
@ -31,12 +44,12 @@ def create_proxy(pool: Pool, origin: Origin) -> bool:
|
||||||
continue
|
continue
|
||||||
next_subgroup = provider.next_subgroup(origin.group_id) # type: ignore[attr-defined]
|
next_subgroup = provider.next_subgroup(origin.group_id) # type: ignore[attr-defined]
|
||||||
if next_subgroup is None:
|
if next_subgroup is None:
|
||||||
continue
|
continue # Exceeded maximum number of subgroups and last subgroup is full
|
||||||
proxy = Proxy()
|
proxy = Proxy()
|
||||||
proxy.pool_id = pool.id
|
proxy.pool_id = pool.id
|
||||||
proxy.origin_id = origin.id
|
proxy.origin_id = origin.id
|
||||||
proxy.provider = provider.provider # type: ignore[attr-defined]
|
proxy.provider = provider.provider # type: ignore[attr-defined]
|
||||||
proxy.psg = provider.next_subgroup(origin.group_id) # type: ignore[attr-defined]
|
proxy.psg = next_subgroup
|
||||||
# The random usage below is good enough for its purpose: to create a slug that
|
# The random usage below is good enough for its purpose: to create a slug that
|
||||||
# hasn't been used recently.
|
# hasn't been used recently.
|
||||||
proxy.slug = tldextract.extract(origin.domain_name).domain[:5] + ''.join(
|
proxy.slug = tldextract.extract(origin.domain_name).domain[:5] + ''.join(
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""next generation bridge management
|
||||||
|
|
||||||
|
Revision ID: 89a74e347d85
|
||||||
|
Revises: 09b5f4bd75b8
|
||||||
|
Create Date: 2023-01-25 12:51:28.620426
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '89a74e347d85'
|
||||||
|
down_revision = '09b5f4bd75b8'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.drop_constraint('fk_bridge_conf_id_bridge_conf', 'bridge')
|
||||||
|
op.drop_constraint('fk_bridge_conf_group_id_group', 'bridge_conf')
|
||||||
|
op.drop_constraint('pk_bridge', 'bridge')
|
||||||
|
op.drop_constraint('pk_bridge_conf', 'bridge_conf')
|
||||||
|
op.rename_table('bridge', 'original_bridge')
|
||||||
|
op.rename_table('bridge_conf', 'original_bridge_conf')
|
||||||
|
op.create_table('bridge_conf',
|
||||||
|
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('pool_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('method', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('target_number', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('max_number', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('expiry_hours', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('provider_allocation', sa.Enum('RANDOM', 'COST', name='providerallocation'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['pool_id'], ['pool.id'], name=op.f('fk_bridge_conf_pool_id_pool')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_bridge_conf'))
|
||||||
|
)
|
||||||
|
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'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('bridge')
|
||||||
|
op.drop_table('bridge_conf')
|
||||||
|
op.rename_table('original_bridge_conf', 'bridge_conf')
|
||||||
|
op.create_primary_key('pk_bridge_conf', 'bridge_conf', ['id'])
|
||||||
|
op.create_foreign_key('fk_bridge_conf_group_id_group', 'bridge_conf', 'group', ['group_id'], ['id'])
|
||||||
|
op.rename_table('original_bridge', 'bridge')
|
||||||
|
op.create_primary_key('pk_bridge', 'bridge', ['id'])
|
||||||
|
op.create_foreign_key('fk_bridge_conf_id_bridge_conf', 'bridge', 'bridge_conf', ['conf_id'], ['id'])
|
Loading…
Add table
Add a link
Reference in a new issue