feat(bridges): next generation bridge management

This commit is contained in:
Iain Learmonth 2023-01-26 15:42:25 +00:00
parent 20fad30a06
commit 05285a4ae6
12 changed files with 329 additions and 89 deletions

View file

@ -44,6 +44,15 @@ portal.register_blueprint(storage, url_prefix="/state")
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")
def calculate_mirror_expiry(s: datetime) -> str:
expiry = s + timedelta(days=3)

View file

@ -9,7 +9,6 @@ from app.portal.util import LifecycleForm
bp = Blueprint("bridge", __name__)
_SECTION_TEMPLATE_VARS = {
"section": "bridge",
"help_url": "https://bypass.censorship.guide/user/bridges.html"
@ -45,3 +44,24 @@ def bridge_blocked(bridge_id: int) -> ResponseReturnValue:
message=bridge.hashed_fingerprint,
form=form,
**_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)

View file

@ -9,7 +9,7 @@ from wtforms import SelectField, StringField, IntegerField, SubmitField
from wtforms.validators import DataRequired, NumberRange
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.portal.util import response_404, view_lifecycle
@ -23,17 +23,34 @@ _SECTION_TEMPLATE_VARS = {
class NewBridgeConfForm(FlaskForm): # type: ignore
provider = SelectField('Provider', validators=[DataRequired()])
method = SelectField('Distribution Method', validators=[DataRequired()])
description = StringField('Description')
group = SelectField('Group', validators=[DataRequired()])
number = IntegerField('Number', validators=[NumberRange(1, message="One or more bridges must be created")])
pool = SelectField('Pool', validators=[DataRequired()])
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')
class EditBridgeConfForm(FlaskForm): # type: ignore
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')
@ -52,13 +69,7 @@ def bridgeconf_list() -> ResponseReturnValue:
@bp.route("/new/<group_id>", methods=['GET', 'POST'])
def bridgeconf_new(group_id: Optional[int] = None) -> ResponseReturnValue:
form = NewBridgeConfForm()
form.group.choices = [(x.id, x.group_name) for x in Group.query.all()]
form.provider.choices = [
("aws", "AWS Lightsail"),
("hcloud", "Hetzner Cloud"),
("ovh", "OVH Public Cloud"),
("gandi", "GandiCloud VPS")
]
form.pool.choices = [(x.id, x.pool_name) for x in Pool.query.filter(Pool.destroyed.is_(None)).all()]
form.method.choices = [
("any", "Any (BridgeDB)"),
("email", "E-Mail (BridgeDB)"),
@ -67,18 +78,19 @@ def bridgeconf_new(group_id: Optional[int] = None) -> ResponseReturnValue:
("none", "None (Private)")
]
if form.validate_on_submit():
bridge_conf = BridgeConf()
bridge_conf.group_id = form.group.data
bridge_conf.provider = form.provider.data
bridge_conf.method = form.method.data
bridge_conf.description = form.description.data
bridge_conf.number = form.number.data
bridge_conf.created = datetime.utcnow()
bridge_conf.updated = datetime.utcnow()
bridgeconf = BridgeConf()
bridgeconf.pool_id = form.pool.data
bridgeconf.method = form.method.data
bridgeconf.description = form.description.data
bridgeconf.target_number = form.target_number.data
bridgeconf.max_number = form.max_number.data
bridgeconf.expiry_hours = form.expiry_hours.data
bridgeconf.created = datetime.utcnow()
bridgeconf.updated = datetime.utcnow()
try:
db.session.add(bridge_conf)
db.session.add(bridgeconf)
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"))
except exc.SQLAlchemyError:
flash("Failed to create new bridge configuration.", "danger")
@ -100,10 +112,15 @@ def bridgeconf_edit(bridgeconf_id: int) -> ResponseReturnValue:
**_SECTION_TEMPLATE_VARS),
status=404)
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():
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()
try:
db.session.commit()

View file

@ -4,7 +4,7 @@
{% block content %}
<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">
{{ render_form(form) }}

View file

@ -453,10 +453,11 @@
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Group</th>
<th scope="col">Provider</th>
<th scope="col">Pool</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>
</tr>
</thead>
@ -465,11 +466,12 @@
{% if not bridgeconf.destroyed %}
<tr class="align-middle">
<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>{{ bridgeconf.provider }}</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>
<a href="{{ url_for("portal.bridgeconf.bridgeconf_edit", bridgeconf_id=bridgeconf.id) }}"
class="btn btn-primary btn-sm">View/Edit</a>
@ -489,7 +491,7 @@
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Group</th>
<th scope="col">Pool</th>
<th scope="col">Configuration</th>
<th scope="col">Nickname</th>
<th scope="col">Hashed Fingerprint</th>
@ -502,9 +504,9 @@
{% if not bridge.destroyed %}
<tr class="align-middle{% if bridge.deprecated %} bg-warning{% endif %}">
<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>{{ bridge.conf.description }} ({{ bridge.conf.provider }}/{{ bridge.conf.method }})</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 }}
@ -541,7 +543,9 @@
<td>
{% if bridge.deprecated %}
<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 %}
<a href="{{ url_for("portal.bridge.bridge_blocked", bridge_id=bridge.id) }}"
class="btn btn-warning btn-sm">Mark blocked</a>