diff --git a/app/lists/mirror_mapping.py b/app/lists/mirror_mapping.py index 803822e..248a599 100644 --- a/app/lists/mirror_mapping.py +++ b/app/lists/mirror_mapping.py @@ -1,12 +1,15 @@ # pylint: disable=too-few-public-methods import builtins +from datetime import datetime, timedelta from typing import Dict, List, Union, Optional from flask import current_app from pydantic import BaseModel, Field +from sqlalchemy import or_ from tldextract import extract +from app.extensions import db from app.models.base import Group, Pool from app.models.mirrors import Proxy @@ -17,6 +20,9 @@ class MMMirror(BaseModel): origin_domain_root: str = Field(description="The registered domain name of the origin, excluding subdomains") valid_from: str = Field(description="The date on which the mirror was added to the system") valid_to: Optional[str] = Field(description="The date on which the mirror was decommissioned") + # countries: List[Tuple[str, int]] = Field(description="A list mapping of risk levels to country") + country: Optional[str] = Field(description="The country code of the country in which the origin is targeted") + risk: int = Field(description="A risk score for the origin in the target country") class MirrorMapping(BaseModel): @@ -35,17 +41,40 @@ class MirrorMapping(BaseModel): def mirror_mapping(_: Optional[Pool]) -> Dict[str, Union[str, Dict[str, str]]]: + one_week_ago = datetime.utcnow() - timedelta(days=7) + + proxies = ( + db.session.query(Proxy) # type: ignore[no-untyped-call] + .filter(or_(Proxy.destroyed.is_(None), Proxy.destroyed > one_week_ago)) + .filter(Proxy.url.is_not(None)) + .all() + ) + + result = {} + for proxy in proxies: + if proxy.origin.countries: # Check if there are any associated countries + risk_levels = proxy.origin.risk_level.items() + highest_risk_country = max(risk_levels, key=lambda x: x[1]) + highest_risk_country_code = highest_risk_country[0] + highest_risk_level = highest_risk_country[1] + else: + highest_risk_country_code = "ZZ" + highest_risk_level = 0 + + result[proxy.url.lstrip("https://")] = MMMirror( + origin_domain=proxy.origin.domain_name, + origin_domain_normalized=proxy.origin.domain_name.replace("www.", ""), + origin_domain_root=extract(proxy.origin.domain_name).registered_domain, + valid_from=proxy.added.isoformat(), + valid_to=proxy.destroyed.isoformat() if proxy.destroyed is not None else None, + # countries=[], # TODO: countries, + country=highest_risk_country_code, + risk=highest_risk_level + ) + return MirrorMapping( - version="1.1", - mappings={ - d.url.lstrip("https://"): MMMirror( - origin_domain=d.origin.domain_name, - origin_domain_normalized=d.origin.domain_name.replace("www.", ""), - origin_domain_root=extract(d.origin.domain_name).registered_domain, - valid_from=d.added.isoformat(), - valid_to=d.destroyed.isoformat() if d.destroyed is not None else None - ) for d in Proxy.query.all() if d.url is not None - }, + version="1.2", + mappings=result, s3_buckets=[ f"{current_app.config['GLOBAL_NAMESPACE']}-{g.group_name.lower()}-logs-cloudfront" for g in Group.query.filter(Group.destroyed.is_(None)).all() diff --git a/app/models/__init__.py b/app/models/__init__.py index 6f4507a..b7b4b9e 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,7 +1,7 @@ import logging from abc import abstractmethod from datetime import datetime -from typing import Union, List, Optional, Any +from typing import Union, List, Optional, Any, Dict from app.brm.brn import BRN from app.extensions import db @@ -37,6 +37,21 @@ class AbstractConfiguration(db.Model): # type: ignore ] +class Deprecation(db.Model): # type: ignore[name-defined,misc] + id = db.Column(db.Integer, primary_key=True) + resource_type = db.Column(db.String(50)) + resource_id = db.Column(db.Integer) + deprecated_at = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False) + meta = db.Column(db.JSON()) + reason = db.Column(db.String(), nullable=False) + + @property + def resource(self) -> "AbstractResource": + from app.models.mirrors import Proxy + model = {'Proxy': Proxy}[self.resource_type] + return model.query.get(self.resource_id) # type: ignore[no-any-return] + + class AbstractResource(db.Model): # type: ignore __abstract__ = True @@ -72,12 +87,14 @@ class AbstractResource(db.Model): # type: ignore def brn(self) -> BRN: raise NotImplementedError() - def deprecate(self, *, reason: str) -> bool: + def deprecate(self, *, reason: str, meta: Optional[Dict[str, Any]] = None) -> bool: """ Marks the resource as deprecated. In the event that the resource was already deprecated, no change will be recorded and the function will return False. :param reason: an opaque string that records the deprecation reason + :param meta: metadata associated with the deprecation reason, such as the circumstances in which censorship was + detected :return: if the proxy was deprecated """ if self.deprecated is None: @@ -85,10 +102,26 @@ class AbstractResource(db.Model): # type: ignore self.deprecated = datetime.utcnow() self.deprecation_reason = reason self.updated = datetime.utcnow() + if reason not in [d.reason for d in self.deprecations]: + new_deprecation = Deprecation( + resource_type=type(self).__name__, + resource_id=self.id, + reason=reason, + meta=meta + ) + db.session.add(new_deprecation) return True - logging.info("Not deprecating %s (reason=%s) because it's already deprecated", self.brn, reason) + logging.info("Not deprecating %s (reason=%s) because it's already deprecated with that reason.", + self.brn, reason) return False + @property + def deprecations(self) -> List[Deprecation]: + return Deprecation.query.filter_by( # type: ignore[no-any-return] + resource_type='Proxy', + resource_id=self.id + ).all() + def destroy(self) -> None: """ Marks the resource for destruction. diff --git a/app/models/mirrors.py b/app/models/mirrors.py index c35e58a..46a80ea 100644 --- a/app/models/mirrors.py +++ b/app/models/mirrors.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional, List, Union, Any, Dict from tldextract import extract @@ -8,9 +8,17 @@ from werkzeug.datastructures import FileStorage from app.brm.brn import BRN from app.brm.utils import thumbnail_uploaded_image, create_data_uri, normalize_color from app.extensions import db -from app.models import AbstractConfiguration, AbstractResource +from app.models import AbstractConfiguration, AbstractResource, Deprecation from app.models.onions import Onion +country_origin = db.Table( + 'country_origin', + db.metadata, + db.Column('country_id', db.ForeignKey('country.id'), primary_key=True), + db.Column('origin_id', db.ForeignKey('origin.id'), primary_key=True), + extend_existing=True, +) + class Origin(AbstractConfiguration): group_id = db.Column(db.Integer, db.ForeignKey("group.id"), nullable=False) @@ -18,9 +26,11 @@ class Origin(AbstractConfiguration): auto_rotation = db.Column(db.Boolean, nullable=False) smart = db.Column(db.Boolean(), nullable=False) assets = db.Column(db.Boolean(), nullable=False) + risk_level_override = db.Column(db.Integer(), nullable=True) group = db.relationship("Group", back_populates="origins") proxies = db.relationship("Proxy", back_populates="origin") + countries = db.relationship("Country", secondary=country_origin, back_populates='origins') @property def brn(self) -> BRN: @@ -35,7 +45,7 @@ class Origin(AbstractConfiguration): @classmethod def csv_header(cls) -> List[str]: return super().csv_header() + [ - "group_id", "domain_name", "auto_rotation", "smart" + "group_id", "domain_name", "auto_rotation", "smart", "assets", "country" ] def destroy(self) -> None: @@ -51,6 +61,75 @@ class Origin(AbstractConfiguration): domain_name: str = self.domain_name return f"https://{domain_name.replace(tld, onion.onion_name)}.onion" + @property + def risk_level(self) -> Dict[str, int]: + if self.risk_level_override: + return {country.country_code: self.risk_level_override for country in self.countries} + frequency_factor = 0 + recency_factor = 0 + recent_deprecations = ( + db.session.query(Deprecation) # type: ignore[no-untyped-call] + .join(Proxy, + Deprecation.resource_id == Proxy.id) + .join(Origin, Origin.id == Proxy.origin_id) + .filter( + Origin.id == self.id, + Deprecation.resource_type == 'Proxy', + Deprecation.deprecated_at >= datetime.utcnow() - timedelta(hours=168) + ) + .distinct(Proxy.id) + .all() + ) + for deprecation in recent_deprecations: + recency_factor += 1 / max((datetime.utcnow() - deprecation.deprecated_at).total_seconds() // 3600, 1) + frequency_factor += 1 + risk_levels: Dict[str, int] = {} + for country in self.countries: + risk_levels[country.country_code.upper()] = int(max(1, min(10, frequency_factor * recency_factor))) + country.risk_level + return risk_levels + + +class Country(AbstractConfiguration): + @property + def brn(self) -> BRN: + return BRN( + group_id=0, + product="country", + provider="iso3166-1", + resource_type="alpha2", + resource_id=self.country_code + ) + + country_code = db.Column(db.String(2), nullable=False) + risk_level_override = db.Column(db.Integer(), nullable=True) + + origins = db.relationship("Origin", secondary=country_origin, back_populates='countries') + + @property + def risk_level(self) -> int: + if self.risk_level_override: + return int(self.risk_level_override // 2) + frequency_factor = 0 + recency_factor = 0 + recent_deprecations = ( + db.session.query(Deprecation) # type: ignore[no-untyped-call] + .join(Proxy, + Deprecation.resource_id == Proxy.id) + .join(Origin, Origin.id == Proxy.origin_id) + .join(Origin.countries) + .filter( + Country.id == self.id, + Deprecation.resource_type == 'Proxy', + Deprecation.deprecated_at >= datetime.utcnow() - timedelta(hours=168) + ) + .distinct(Proxy.id) + .all() + ) + for deprecation in recent_deprecations: + recency_factor += 1 / max((datetime.utcnow() - deprecation.deprecated_at).total_seconds() // 3600, 1) + frequency_factor += 1 + return int(max(1, min(10, frequency_factor * recency_factor))) + class StaticOrigin(AbstractConfiguration): group_id = db.Column(db.Integer, db.ForeignKey("group.id"), nullable=False) diff --git a/app/portal/__init__.py b/app/portal/__init__.py index 23e8dff..eb1271f 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.country import bp as country from app.portal.cloud import bp as cloud from app.portal.automation import bp as automation from app.portal.bridgeconf import bp as bridgeconf @@ -35,6 +36,7 @@ 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(country, url_prefix="/country") 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/country.py b/app/portal/country.py new file mode 100644 index 0000000..1a5b700 --- /dev/null +++ b/app/portal/country.py @@ -0,0 +1,74 @@ +from datetime import datetime + +import sqlalchemy +from flask import Blueprint, render_template, Response, flash +from flask.typing import ResponseReturnValue +from flask_wtf import FlaskForm +from wtforms import IntegerField, BooleanField, SubmitField + +from app.extensions import db +from app.models.mirrors import Country + +bp = Blueprint("country", __name__) + +_SECTION_TEMPLATE_VARS = { + "section": "country", + "help_url": "https://bypass.censorship.guide/user/countries.html" +} + + +@bp.app_template_filter("country_flag") +def filter_country_flag(country_code: str) -> str: + country_code = country_code.upper() + + # Calculate the regional indicator symbol for each letter in the country code + base = ord('\U0001F1E6') - ord('A') + flag = ''.join([chr(ord(char) + base) for char in country_code]) + + return flag + + +@bp.route('/list') +def country_list() -> ResponseReturnValue: + countries = Country.query.filter(Country.destroyed.is_(None)).all() + print(len(countries)) + return render_template("list.html.j2", + title="Countries", + item="country", + new_link=None, + items=sorted(countries, key=lambda x: x.country_code), + **_SECTION_TEMPLATE_VARS + ) + + +class EditCountryForm(FlaskForm): # type: ignore[misc] + risk_level_override = BooleanField("Force Risk Level Override?") + risk_level_override_number = IntegerField("Forced Risk Level", description="Number from 0 to 20", default=0) + submit = SubmitField('Save Changes') + + +@bp.route('/edit/', methods=['GET', 'POST']) +def country_edit(country_id: int) -> ResponseReturnValue: + country = Country.query.filter(Country.id == country_id).first() + if country is None: + return Response(render_template("error.html.j2", + section="country", + header="404 Country Not Found", + message="The requested country could not be found."), + status=404) + form = EditCountryForm(risk_level_override=country.risk_level_override is not None, + risk_level_override_number=country.risk_level_override) + if form.validate_on_submit(): + if form.risk_level_override.data: + country.risk_level_override = form.risk_level_override_number.data + else: + country.risk_level_override = None + country.updated = datetime.utcnow() + try: + db.session.commit() + flash("Saved changes to country.", "success") + except sqlalchemy.exc.SQLAlchemyError: + flash("An error occurred saving the changes to the country.", "danger") + return render_template("country.html.j2", + section="country", + country=country, form=form) diff --git a/app/portal/origin.py b/app/portal/origin.py index ab48b9c..73457df 100644 --- a/app/portal/origin.py +++ b/app/portal/origin.py @@ -3,17 +3,18 @@ from datetime import datetime from typing import Optional, List import requests +import sqlalchemy from flask import flash, redirect, url_for, render_template, Response, Blueprint from flask.typing import ResponseReturnValue from flask_wtf import FlaskForm from sqlalchemy import exc -from wtforms import StringField, SelectField, SubmitField, BooleanField +from wtforms import StringField, SelectField, SubmitField, BooleanField, IntegerField from wtforms.validators import DataRequired from app.extensions import db from app.models.base import Group -from app.models.mirrors import Origin -from app.portal.util import response_404, view_lifecycle +from app.models.mirrors import Origin, Country +from app.portal.util import response_404, view_lifecycle, LifecycleForm bp = Blueprint("origin", __name__) @@ -28,15 +29,22 @@ class NewOriginForm(FlaskForm): # type: ignore submit = SubmitField('Save Changes') -class EditOriginForm(FlaskForm): # type: ignore +class EditOriginForm(FlaskForm): # type: ignore[misc] description = StringField('Description', validators=[DataRequired()]) group = SelectField('Group', validators=[DataRequired()]) auto_rotate = BooleanField("Enable auto-rotation?") smart_proxy = BooleanField("Requires smart proxy?") asset_domain = BooleanField("Used to host assets for other domains?", default=False) + risk_level_override = BooleanField("Force Risk Level Override?") + risk_level_override_number = IntegerField("Forced Risk Level", description="Number from 0 to 20", default=0) submit = SubmitField('Save Changes') +class CountrySelectForm(FlaskForm): # type: ignore[misc] + country = SelectField("Country", validators=[DataRequired()]) + submit = SubmitField('Save Changes', render_kw={"class": "btn btn-success"}) + + def final_domain_name(domain_name: str) -> str: session = requests.Session() r = session.get(f"https://{domain_name}/", allow_redirects=True, timeout=10) @@ -84,7 +92,9 @@ def origin_edit(origin_id: int) -> ResponseReturnValue: description=origin.description, auto_rotate=origin.auto_rotation, smart_proxy=origin.smart, - asset_domain=origin.assets) + asset_domain=origin.assets, + risk_level_override=origin.risk_level_override is not None, + risk_level_override_number=origin.risk_level_override) 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 @@ -92,12 +102,16 @@ def origin_edit(origin_id: int) -> ResponseReturnValue: origin.auto_rotation = form.auto_rotate.data origin.smart = form.smart_proxy.data origin.assets = form.asset_domain.data + if form.risk_level_override.data: + origin.risk_level_override = form.risk_level_override_number.data + else: + origin.risk_level_override = None origin.updated = datetime.utcnow() try: db.session.commit() - flash("Saved changes to group.", "success") + flash(f"Saved changes for origin {origin.domain_name}.", "success") except exc.SQLAlchemyError: - flash("An error occurred saving the changes to the group.", "danger") + flash("An error occurred saving the changes to the origin.", "danger") return render_template("origin.html.j2", section="origin", origin=origin, form=form) @@ -144,3 +158,74 @@ def origin_destroy(origin_id: int) -> ResponseReturnValue: resource=origin, action="destroy" ) + + +@bp.route('/country_remove//', methods=['GET', 'POST']) +def origin_country_remove(origin_id: int, country_id: int) -> ResponseReturnValue: + origin = Origin.query.filter(Origin.id == origin_id).first() + if origin is None: + return Response(render_template("error.html.j2", + section="origin", + header="404 Pool Not Found", + message="The requested origin could not be found."), + status=404) + country = Country.query.filter(Country.id == country_id).first() + if country is None: + return Response(render_template("error.html.j2", + section="origin", + header="404 Country Not Found", + message="The requested country could not be found."), + status=404) + if country not in origin.countries: + return Response(render_template("error.html.j2", + section="origin", + header="404 Country Not In Pool", + message="The requested country could not be found in the specified origin."), + status=404) + form = LifecycleForm() + if form.validate_on_submit(): + origin.countries.remove(country) + try: + db.session.commit() + flash("Saved changes to origin.", "success") + return redirect(url_for("portal.origin.origin_edit", origin_id=origin.id)) + except sqlalchemy.exc.SQLAlchemyError: + flash("An error occurred saving the changes to the origin.", "danger") + return render_template("lifecycle.html.j2", + header=f"Remove {country.country_name} from the {origin.domain_name} origin?", + message="Stop monitoring in this country.", + section="origin", + origin=origin, form=form) + + +@bp.route('/country_add/', methods=['GET', 'POST']) +def origin_country_add(origin_id: int) -> ResponseReturnValue: + origin = Origin.query.filter(Origin.id == origin_id).first() + if origin is None: + return Response(render_template("error.html.j2", + section="origin", + header="404 Origin Not Found", + message="The requested origin could not be found."), + status=404) + form = CountrySelectForm() + form.country.choices = [(x.id, f"{x.country_code} - {x.description}") for x in Country.query.all()] + if form.validate_on_submit(): + country = Country.query.filter(Country.id == form.country.data).first() + if country is None: + return Response(render_template("error.html.j2", + section="origin", + header="404 Country Not Found", + message="The requested country could not be found."), + status=404) + origin.countries.append(country) + try: + db.session.commit() + flash("Saved changes to origin.", "success") + return redirect(url_for("portal.origin.origin_edit", origin_id=origin.id)) + except sqlalchemy.exc.SQLAlchemyError: + flash("An error occurred saving the changes to the origin.", "danger") + return render_template("lifecycle.html.j2", + header=f"Add a country to {origin.domain_name}", + message="Enable monitoring from this country:", + section="origin", + origin=origin, form=form) diff --git a/app/portal/templates/base.html.j2 b/app/portal/templates/base.html.j2 index 3625ee0..d351f46 100644 --- a/app/portal/templates/base.html.j2 +++ b/app/portal/templates/base.html.j2 @@ -87,6 +87,12 @@ {{ icon("cloud") }} Cloud Accounts +