Initial import
This commit is contained in:
commit
09f0b0672d
64 changed files with 3735 additions and 0 deletions
156
.gitignore
vendored
Normal file
156
.gitignore
vendored
Normal file
|
@ -0,0 +1,156 @@
|
|||
# Secrets
|
||||
config.yaml
|
||||
app/example.db*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
21
.gitlab-ci.yml
Normal file
21
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,21 @@
|
|||
image: python:3.8-alpine
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- pip install -U sphinx sphinx-press-theme
|
||||
- sphinx-build -b html docs public
|
||||
rules:
|
||||
- if: $CI_COMMIT_REF_NAME != $CI_DEFAULT_BRANCH
|
||||
|
||||
pages:
|
||||
stage: deploy
|
||||
script:
|
||||
- pip install -U sphinx sphinx-press-theme
|
||||
- sphinx-build -b html docs public
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
rules:
|
||||
- if: $CI_COMMIT_REF_NAME == "docs"
|
||||
|
55
app/__init__.py
Normal file
55
app/__init__.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
import boto3 as boto3
|
||||
from flask import Flask, jsonify, Response, redirect, url_for
|
||||
import yaml
|
||||
|
||||
from app.extensions import db
|
||||
from app.extensions import migrate
|
||||
from app.extensions import bootstrap
|
||||
from app.mirror_sites import mirror_sites
|
||||
from app.models import Group, Origin, Proxy, Mirror
|
||||
from app.portal import portal
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_file("../config.yaml", load=yaml.safe_load)
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db, render_as_batch=True)
|
||||
bootstrap.init_app(app)
|
||||
|
||||
app.register_blueprint(portal, url_prefix="/portal")
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return redirect(url_for("portal.portal_home"))
|
||||
|
||||
|
||||
@app.route('/import/cloudfront')
|
||||
def import_cloudfront():
|
||||
a = ""
|
||||
not_found = []
|
||||
cloudfront = boto3.client('cloudfront',
|
||||
aws_access_key_id=app.config['AWS_ACCESS_KEY'],
|
||||
aws_secret_access_key=app.config['AWS_SECRET_KEY'])
|
||||
dist_paginator = cloudfront.get_paginator('list_distributions')
|
||||
page_iterator = dist_paginator.paginate()
|
||||
for page in page_iterator:
|
||||
for dist in page['DistributionList']['Items']:
|
||||
res = Proxy.query.all()
|
||||
matches = [r for r in res if r.origin.domain_name == dist['Comment'][8:]]
|
||||
if not matches:
|
||||
not_found.append(dist['Comment'][8:])
|
||||
continue
|
||||
a += f"# {dist['Comment'][8:]}\n"
|
||||
a += f"terraform import module.cloudfront_{matches[0].id}.aws_cloudfront_distribution.this {dist['Id']}\n"
|
||||
for n in not_found:
|
||||
a += f"# Not found: {n}\n"
|
||||
return Response(a, content_type="text/plain")
|
||||
|
||||
|
||||
@app.route('/mirrorSites.json')
|
||||
def json_mirror_sites():
|
||||
return jsonify(mirror_sites)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
27
app/alarms.py
Normal file
27
app/alarms.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from app.extensions import db
|
||||
from app.models import Alarm
|
||||
|
||||
|
||||
def _get_alarm(target: str,
|
||||
alarm_type: str,
|
||||
proxy_id=None,
|
||||
create_if_missing=True):
|
||||
if target == "proxy":
|
||||
alarm = Alarm.query.filter(
|
||||
Alarm.target == "proxy",
|
||||
Alarm.alarm_type == alarm_type,
|
||||
Alarm.proxy_id == proxy_id
|
||||
).first()
|
||||
if create_if_missing and alarm is None:
|
||||
alarm = Alarm()
|
||||
alarm.target = target
|
||||
alarm.alarm_type = alarm_type
|
||||
if target == "proxy":
|
||||
alarm.proxy_id = proxy_id
|
||||
db.session.add(alarm)
|
||||
db.session.commit()
|
||||
return alarm
|
||||
|
||||
|
||||
def get_proxy_alarm(proxy_id: int, alarm_type: str):
|
||||
return _get_alarm("proxy", "alarm_type", proxy_id=proxy_id)
|
17
app/extensions.py
Normal file
17
app/extensions.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from flask_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bootstrap import Bootstrap5
|
||||
from sqlalchemy import MetaData
|
||||
|
||||
convention = {
|
||||
"ix": 'ix_%(column_0_label)s',
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s"
|
||||
}
|
||||
|
||||
metadata = MetaData(naming_convention=convention)
|
||||
db = SQLAlchemy(metadata=metadata)
|
||||
migrate = Migrate()
|
||||
bootstrap = Bootstrap5()
|
52
app/mirror_sites.py
Normal file
52
app/mirror_sites.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
from tldextract import extract
|
||||
|
||||
from app.models import Origin, Bridge, Proxy
|
||||
|
||||
|
||||
def mirror_sites():
|
||||
return {
|
||||
"version": "2.0",
|
||||
"sites": [{
|
||||
"main_domain": x.domain_name.replace("www.", ""),
|
||||
"available_alternatives": [
|
||||
{
|
||||
"proto": "tor" if ".onion" in a.url else "https",
|
||||
"type": "eotk" if ".onion" in a.url else "mirror",
|
||||
"created_at": str(a.added),
|
||||
"updated_at": str(a.updated),
|
||||
"url": a.url
|
||||
} for a in x.mirrors if not a.deprecated and not a.destroyed
|
||||
] + [
|
||||
{
|
||||
"proto": "https",
|
||||
"type": "mirror",
|
||||
"created_at": str(a.added),
|
||||
"updated_at": str(a.updated),
|
||||
"url": a.url
|
||||
} for a in x.proxies if
|
||||
a.url is not None and not a.deprecated and not a.destroyed and a.provider == "cloudfront"
|
||||
]} for x in Origin.query.order_by(Origin.domain_name).all()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def bridgelines():
|
||||
return {
|
||||
"version": "1.0",
|
||||
"bridgelines": [
|
||||
b.bridgeline for b in Bridge.query.filter(
|
||||
Bridge.destroyed == None,
|
||||
Bridge.bridgeline != None
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def mirror_mapping():
|
||||
return {
|
||||
d.url.lstrip("https://"): {
|
||||
"origin_domain": d.origin.domain_name,
|
||||
"origin_domain_normalized": d.origin.domain_name.lstrip("www."),
|
||||
"origin_domain_root": extract(d.origin.domain_name).registered_domain
|
||||
} for d in Proxy.query.all() if d.url is not None
|
||||
}
|
238
app/models.py
Normal file
238
app/models.py
Normal file
|
@ -0,0 +1,238 @@
|
|||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class AbstractConfiguration(db.Model):
|
||||
__abstract__ = True
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
added = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
updated = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
destroyed = db.Column(db.DateTime(), nullable=True)
|
||||
|
||||
def destroy(self):
|
||||
self.destroyed = datetime.utcnow()
|
||||
self.updated = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class AbstractResource(db.Model):
|
||||
__abstract__ = True
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
added = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
updated = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
deprecated = db.Column(db.DateTime(), nullable=True)
|
||||
destroyed = db.Column(db.DateTime(), nullable=True)
|
||||
|
||||
def deprecate(self):
|
||||
self.deprecated = datetime.utcnow()
|
||||
self.updated = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def destroy(self):
|
||||
if self.deprecated is None:
|
||||
self.deprecated = datetime.utcnow()
|
||||
self.destroyed = datetime.utcnow()
|
||||
self.updated = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} #{self.id}>"
|
||||
|
||||
|
||||
class Group(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
group_name = db.Column(db.String(80), unique=True, nullable=False)
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
eotk = db.Column(db.Boolean())
|
||||
added = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
updated = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
|
||||
origins = db.relationship("Origin", back_populates="group")
|
||||
bridgeconfs = db.relationship("BridgeConf", back_populates="group")
|
||||
alarms = db.relationship("Alarm", back_populates="group")
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.group_name,
|
||||
"description": self.description,
|
||||
"added": self.added,
|
||||
"updated": self.updated
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return '<Group %r>' % self.group_name
|
||||
|
||||
|
||||
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)
|
||||
|
||||
group = db.relationship("Group", back_populates="origins")
|
||||
mirrors = db.relationship("Mirror", back_populates="origin")
|
||||
proxies = db.relationship("Proxy", back_populates="origin")
|
||||
alarms = db.relationship("Alarm", back_populates="origin")
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"group_id": self.group.id,
|
||||
"group_name": self.group.group_name,
|
||||
"domain_name": self.domain_name,
|
||||
"description": self.description,
|
||||
"added": self.added,
|
||||
"updated": self.updated
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return '<Origin %r>' % self.domain_name
|
||||
|
||||
|
||||
class Proxy(AbstractResource):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
origin_id = db.Column(db.Integer, db.ForeignKey("origin.id"), nullable=False)
|
||||
provider = db.Column(db.String(20), nullable=False)
|
||||
slug = db.Column(db.String(20), nullable=True)
|
||||
terraform_updated = db.Column(db.DateTime(), nullable=True)
|
||||
url = db.Column(db.String(255), nullable=True)
|
||||
|
||||
origin = db.relationship("Origin", back_populates="proxies")
|
||||
alarms = db.relationship("Alarm", back_populates="proxy")
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"origin_id": self.origin.id,
|
||||
"origin_domain_name": self.origin.domain_name,
|
||||
"provider": self.provider,
|
||||
"slug": self.slug,
|
||||
"added": self.added,
|
||||
"updated": self.updated
|
||||
}
|
||||
|
||||
|
||||
class Mirror(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
origin_id = db.Column(db.Integer, db.ForeignKey("origin.id"), nullable=False)
|
||||
url = db.Column(db.String(255), unique=True, nullable=False)
|
||||
added = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
updated = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
deprecated = db.Column(db.DateTime(), nullable=True)
|
||||
destroyed = db.Column(db.DateTime(), nullable=True)
|
||||
|
||||
origin = db.relationship("Origin", back_populates="mirrors")
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"origin_id": self.origin_id,
|
||||
"origin_domain_name": self.origin.domain_name,
|
||||
"url": self.url,
|
||||
"added": self.added,
|
||||
"updated": self.updated
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return '<Mirror %r_%r>' % (self.origin.domain_name, self.id)
|
||||
|
||||
|
||||
class AlarmState(enum.Enum):
|
||||
UNKNOWN = 0
|
||||
OK = 1
|
||||
WARNING = 2
|
||||
CRITICAL = 3
|
||||
|
||||
|
||||
class Alarm(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
target = db.Column(db.String(60), nullable=False)
|
||||
group_id = db.Column(db.Integer, db.ForeignKey("group.id"))
|
||||
origin_id = db.Column(db.Integer, db.ForeignKey("origin.id"))
|
||||
proxy_id = db.Column(db.Integer, db.ForeignKey("proxy.id"))
|
||||
bridge_id = db.Column(db.Integer, db.ForeignKey("bridge.id"))
|
||||
alarm_type = db.Column(db.String(255), nullable=False)
|
||||
alarm_state = db.Column(db.Enum(AlarmState), default=AlarmState.UNKNOWN, nullable=False)
|
||||
state_changed = db.Column(db.DateTime(), nullable=False)
|
||||
last_updated = db.Column(db.DateTime())
|
||||
text = db.Column(db.String(255))
|
||||
|
||||
group = db.relationship("Group", back_populates="alarms")
|
||||
origin = db.relationship("Origin", back_populates="alarms")
|
||||
proxy = db.relationship("Proxy", back_populates="alarms")
|
||||
bridge = db.relationship("Bridge", back_populates="alarms")
|
||||
|
||||
def update_state(self, state: AlarmState, text: str):
|
||||
if self.state != state:
|
||||
self.state_changed = datetime.utcnow()
|
||||
self.alarm_state = state
|
||||
self.text = text
|
||||
self.last_updated = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class BridgeConf(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
group_id = db.Column(db.Integer, db.ForeignKey("group.id"), nullable=False)
|
||||
provider = db.Column(db.String(20), nullable=False)
|
||||
method = db.Column(db.String(20), nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
number = db.Column(db.Integer())
|
||||
added = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
updated = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
destroyed = db.Column(db.DateTime(), nullable=True)
|
||||
|
||||
group = db.relationship("Group", back_populates="bridgeconfs")
|
||||
bridges = db.relationship("Bridge", back_populates="conf")
|
||||
|
||||
def destroy(self):
|
||||
self.destroyed = datetime.utcnow()
|
||||
self.updated = datetime.utcnow()
|
||||
for bridge in self.bridges:
|
||||
if bridge.destroyed is None:
|
||||
bridge.destroyed = datetime.utcnow()
|
||||
bridge.updated = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class Bridge(AbstractResource):
|
||||
conf_id = db.Column(db.Integer, db.ForeignKey("bridge_conf.id"), nullable=False)
|
||||
terraform_updated = db.Column(db.DateTime(), nullable=True)
|
||||
nickname = db.Column(db.String(255), nullable=True)
|
||||
fingerprint = db.Column(db.String(255), nullable=True)
|
||||
hashed_fingerprint = db.Column(db.String(255), nullable=True)
|
||||
bridgeline = db.Column(db.String(255), nullable=True)
|
||||
|
||||
conf = db.relationship("BridgeConf", back_populates="bridges")
|
||||
alarms = db.relationship("Alarm", back_populates="bridge")
|
||||
|
||||
|
||||
class MirrorList(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
provider = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
format = db.Column(db.String(20), nullable=False)
|
||||
container = db.Column(db.String(255), nullable=False)
|
||||
branch = db.Column(db.String(255), nullable=False)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
added = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
updated = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
|
||||
deprecated = db.Column(db.DateTime(), nullable=True)
|
||||
destroyed = db.Column(db.DateTime(), nullable=True)
|
||||
|
||||
def destroy(self):
|
||||
self.destroyed = datetime.utcnow()
|
||||
self.updated = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def url(self):
|
||||
if self.provider == "gitlab":
|
||||
return f"https://gitlab.com/{self.container}/-/raw/{self.branch}/{self.filename}"
|
||||
if self.provider == "github":
|
||||
return f"https://raw.githubusercontent.com/{self.container}/{self.branch}/{self.filename}"
|
||||
if self.provider == "s3":
|
||||
return f"s3://{self.container}/{self.filename}"
|
384
app/portal/__init__.py
Normal file
384
app/portal/__init__.py
Normal file
|
@ -0,0 +1,384 @@
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, render_template, Response, flash, redirect, url_for, request
|
||||
from sqlalchemy import exc, desc, or_
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Group, Origin, Proxy, Alarm, BridgeConf, Bridge, MirrorList, AbstractResource
|
||||
from app.portal.forms import EditGroupForm, NewGroupForm, NewOriginForm, EditOriginForm, LifecycleForm, \
|
||||
NewBridgeConfForm, EditBridgeConfForm, NewMirrorListForm
|
||||
|
||||
portal = Blueprint("portal", __name__, template_folder="templates", static_folder="static")
|
||||
|
||||
|
||||
@portal.app_template_filter("mirror_expiry")
|
||||
def calculate_mirror_expiry(s):
|
||||
expiry = s + timedelta(days=3)
|
||||
countdown = expiry - datetime.utcnow()
|
||||
if countdown.days == 0:
|
||||
return f"{countdown.seconds // 3600} hours"
|
||||
return f"{countdown.days} days"
|
||||
|
||||
|
||||
@portal.route("/")
|
||||
def portal_home():
|
||||
return render_template("home.html.j2", section="home")
|
||||
|
||||
|
||||
@portal.route("/groups")
|
||||
def view_groups():
|
||||
groups = Group.query.order_by(Group.group_name).all()
|
||||
return render_template("groups.html.j2", section="group", groups=groups)
|
||||
|
||||
|
||||
@portal.route("/group/new", methods=['GET', 'POST'])
|
||||
def new_group():
|
||||
form = NewGroupForm()
|
||||
if form.validate_on_submit():
|
||||
group = Group()
|
||||
group.group_name = form.group_name.data
|
||||
group.description = form.description.data
|
||||
group.eotk = form.eotk.data
|
||||
group.created = datetime.utcnow()
|
||||
group.updated = datetime.utcnow()
|
||||
try:
|
||||
db.session.add(group)
|
||||
db.session.commit()
|
||||
flash(f"Created new group {group.group_name}.", "success")
|
||||
return redirect(url_for("portal.edit_group", group_id=group.id))
|
||||
except exc.SQLAlchemyError as e:
|
||||
print(e)
|
||||
flash("Failed to create new group.", "danger")
|
||||
return redirect(url_for("portal.view_groups"))
|
||||
return render_template("new.html.j2", section="group", form=form)
|
||||
|
||||
|
||||
@portal.route('/group/edit/<group_id>', methods=['GET', 'POST'])
|
||||
def edit_group(group_id):
|
||||
group = Group.query.filter(Group.id == group_id).first()
|
||||
if group is None:
|
||||
return Response(render_template("error.html.j2",
|
||||
section="group",
|
||||
header="404 Group Not Found",
|
||||
message="The requested group could not be found."),
|
||||
status=404)
|
||||
form = EditGroupForm(description=group.description,
|
||||
eotk=group.eotk)
|
||||
if form.validate_on_submit():
|
||||
group.description = form.description.data
|
||||
group.eotk = form.eotk.data
|
||||
group.updated = datetime.utcnow()
|
||||
try:
|
||||
db.session.commit()
|
||||
flash("Saved changes to group.", "success")
|
||||
except exc.SQLAlchemyError:
|
||||
flash("An error occurred saving the changes to the group.", "danger")
|
||||
return render_template("group.html.j2",
|
||||
section="group",
|
||||
group=group, form=form)
|
||||
|
||||
|
||||
@portal.route("/origin/new", methods=['GET', 'POST'])
|
||||
@portal.route("/origin/new/<group_id>", methods=['GET', 'POST'])
|
||||
def new_origin(group_id=None):
|
||||
form = NewOriginForm()
|
||||
form.group.choices = [(x.id, x.group_name) for x in Group.query.all()]
|
||||
if form.validate_on_submit():
|
||||
origin = Origin()
|
||||
origin.group_id = form.group.data
|
||||
origin.domain_name = form.domain_name.data
|
||||
origin.description = form.description.data
|
||||
origin.created = datetime.utcnow()
|
||||
origin.updated = datetime.utcnow()
|
||||
try:
|
||||
db.session.add(origin)
|
||||
db.session.commit()
|
||||
flash(f"Created new origin {origin.domain_name}.", "success")
|
||||
return redirect(url_for("portal.edit_origin", origin_id=origin.id))
|
||||
except exc.SQLAlchemyError as e:
|
||||
print(e)
|
||||
flash("Failed to create new origin.", "danger")
|
||||
return redirect(url_for("portal.view_origins"))
|
||||
if group_id:
|
||||
form.group.data = group_id
|
||||
return render_template("new.html.j2", section="origin", form=form)
|
||||
|
||||
|
||||
@portal.route('/origin/edit/<origin_id>', methods=['GET', 'POST'])
|
||||
def edit_origin(origin_id):
|
||||
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 = EditOriginForm(group=origin.group_id,
|
||||
description=origin.description)
|
||||
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.updated = datetime.utcnow()
|
||||
try:
|
||||
db.session.commit()
|
||||
flash("Saved changes to group.", "success")
|
||||
except exc.SQLAlchemyError:
|
||||
flash("An error occurred saving the changes to the group.", "danger")
|
||||
return render_template("origin.html.j2",
|
||||
section="origin",
|
||||
origin=origin, form=form)
|
||||
|
||||
|
||||
@portal.route("/origins")
|
||||
def view_origins():
|
||||
origins = Origin.query.order_by(Origin.domain_name).all()
|
||||
return render_template("origins.html.j2", section="origin", origins=origins)
|
||||
|
||||
|
||||
@portal.route("/proxies")
|
||||
def view_proxies():
|
||||
proxies = Proxy.query.filter(Proxy.destroyed == None).order_by(desc(Proxy.updated)).all()
|
||||
return render_template("proxies.html.j2", section="proxy", proxies=proxies)
|
||||
|
||||
|
||||
@portal.route("/proxy/block/<proxy_id>", methods=['GET', 'POST'])
|
||||
def blocked_proxy(proxy_id):
|
||||
proxy = Proxy.query.filter(Proxy.id == proxy_id, Proxy.destroyed == None).first()
|
||||
if proxy is None:
|
||||
return Response(render_template("error.html.j2",
|
||||
header="404 Proxy Not Found",
|
||||
message="The requested proxy could not be found."))
|
||||
form = LifecycleForm()
|
||||
if form.validate_on_submit():
|
||||
proxy.deprecate()
|
||||
flash("Proxy will be shortly replaced.", "success")
|
||||
return redirect(url_for("portal.edit_origin", origin_id=proxy.origin.id))
|
||||
return render_template("lifecycle.html.j2",
|
||||
header=f"Mark proxy for {proxy.origin.domain_name} as blocked?",
|
||||
message=proxy.url,
|
||||
section="proxy",
|
||||
form=form)
|
||||
|
||||
|
||||
@portal.route("/search")
|
||||
def search():
|
||||
query = request.args.get("query")
|
||||
proxies = Proxy.query.filter(or_(Proxy.url.contains(query)), Proxy.destroyed == None).all()
|
||||
origins = Origin.query.filter(or_(Origin.description.contains(query), Origin.domain_name.contains(query))).all()
|
||||
return render_template("search.html.j2", section="home", proxies=proxies, origins=origins)
|
||||
|
||||
|
||||
@portal.route('/alarms')
|
||||
def view_alarms():
|
||||
alarms = Alarm.query.order_by(Alarm.alarm_state, desc(Alarm.state_changed)).all()
|
||||
return render_template("alarms.html.j2", section="alarm", alarms=alarms)
|
||||
|
||||
|
||||
@portal.route('/lists')
|
||||
def view_mirror_lists():
|
||||
mirrorlists = MirrorList.query.filter(MirrorList.destroyed == None).all()
|
||||
return render_template("mirrorlists.html.j2", section="list", mirrorlists=mirrorlists)
|
||||
|
||||
|
||||
@portal.route("/list/destroy/<list_id>")
|
||||
def destroy_mirror_list(list_id):
|
||||
return "not implemented"
|
||||
|
||||
@portal.route("/list/new", methods=['GET', 'POST'])
|
||||
@portal.route("/list/new/<group_id>", methods=['GET', 'POST'])
|
||||
def new_mirror_list(group_id=None):
|
||||
form = NewMirrorListForm()
|
||||
form.provider.choices = [
|
||||
("github", "GitHub"),
|
||||
("gitlab", "GitLab"),
|
||||
("s3", "AWS S3"),
|
||||
]
|
||||
form.format.choices = [
|
||||
("bc2", "Bypass Censorship v2"),
|
||||
("bc3", "Bypass Censorship v3"),
|
||||
("bca", "Bypass Censorship Analytics"),
|
||||
("bridgelines", "Tor Bridge Lines")
|
||||
]
|
||||
form.container.description = "GitHub Project, GitLab Project or AWS S3 bucket name."
|
||||
form.branch.description = "Ignored for AWS S3."
|
||||
if form.validate_on_submit():
|
||||
mirror_list = MirrorList()
|
||||
mirror_list.provider = form.provider.data
|
||||
mirror_list.format = form.format.data
|
||||
mirror_list.description = form.description.data
|
||||
mirror_list.container = form.container.data
|
||||
mirror_list.branch = form.branch.data
|
||||
mirror_list.filename = form.filename.data
|
||||
mirror_list.created = datetime.utcnow()
|
||||
mirror_list.updated = datetime.utcnow()
|
||||
try:
|
||||
db.session.add(mirror_list)
|
||||
db.session.commit()
|
||||
flash(f"Created new mirror list.", "success")
|
||||
return redirect(url_for("portal.view_mirror_lists"))
|
||||
except exc.SQLAlchemyError as e:
|
||||
print(e)
|
||||
flash("Failed to create new mirror list.", "danger")
|
||||
return redirect(url_for("portal.view_mirror_lists"))
|
||||
if group_id:
|
||||
form.group.data = group_id
|
||||
return render_template("new.html.j2", section="list", form=form)
|
||||
|
||||
|
||||
@portal.route("/bridgeconfs")
|
||||
def view_bridgeconfs():
|
||||
bridgeconfs = BridgeConf.query.filter(BridgeConf.destroyed == None).all()
|
||||
return render_template("bridgeconfs.html.j2", section="bridgeconf", bridgeconfs=bridgeconfs)
|
||||
|
||||
|
||||
@portal.route("/bridgeconf/new", methods=['GET', 'POST'])
|
||||
@portal.route("/bridgeconf/new/<group_id>", methods=['GET', 'POST'])
|
||||
def new_bridgeconf(group_id=None):
|
||||
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.method.choices = [
|
||||
("any", "Any (BridgeDB)"),
|
||||
("email", "E-Mail (BridgeDB)"),
|
||||
("moat", "Moat (BridgeDB)"),
|
||||
("https", "HTTPS (BridgeDB)"),
|
||||
("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()
|
||||
try:
|
||||
db.session.add(bridge_conf)
|
||||
db.session.commit()
|
||||
flash(f"Created new bridge configuration {bridge_conf.id}.", "success")
|
||||
return redirect(url_for("portal.view_bridgeconfs"))
|
||||
except exc.SQLAlchemyError as e:
|
||||
print(e)
|
||||
flash("Failed to create new bridge configuration.", "danger")
|
||||
return redirect(url_for("portal.view_bridgeconfs"))
|
||||
if group_id:
|
||||
form.group.data = group_id
|
||||
return render_template("new.html.j2", section="bridgeconf", form=form)
|
||||
|
||||
|
||||
@portal.route("/bridges")
|
||||
def view_bridges():
|
||||
bridges = Bridge.query.filter(Bridge.destroyed == None).all()
|
||||
return render_template("bridges.html.j2", section="bridge", bridges=bridges)
|
||||
|
||||
|
||||
@portal.route('/bridgeconf/edit/<bridgeconf_id>', methods=['GET', 'POST'])
|
||||
def edit_bridgeconf(bridgeconf_id):
|
||||
bridgeconf = BridgeConf.query.filter(BridgeConf.id == bridgeconf_id).first()
|
||||
if bridgeconf 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 = EditBridgeConfForm(description=bridgeconf.description,
|
||||
number=bridgeconf.number)
|
||||
if form.validate_on_submit():
|
||||
bridgeconf.description = form.description.data
|
||||
bridgeconf.number = form.number.data
|
||||
bridgeconf.updated = datetime.utcnow()
|
||||
try:
|
||||
db.session.commit()
|
||||
flash("Saved changes to bridge configuration.", "success")
|
||||
except exc.SQLAlchemyError:
|
||||
flash("An error occurred saving the changes to the bridge configuration.", "danger")
|
||||
return render_template("bridgeconf.html.j2",
|
||||
section="bridgeconf",
|
||||
bridgeconf=bridgeconf, form=form)
|
||||
|
||||
|
||||
@portal.route("/bridge/block/<bridge_id>", methods=['GET', 'POST'])
|
||||
def blocked_bridge(bridge_id):
|
||||
bridge = Bridge.query.filter(Bridge.id == bridge_id, Bridge.destroyed == 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."))
|
||||
form = LifecycleForm()
|
||||
if form.validate_on_submit():
|
||||
bridge.deprecate()
|
||||
flash("Bridge will be shortly replaced.", "success")
|
||||
return redirect(url_for("portal.edit_bridgeconf", bridgeconf_id=bridge.conf_id))
|
||||
return render_template("lifecycle.html.j2",
|
||||
header=f"Mark bridge {bridge.hashed_fingerprint} as blocked?",
|
||||
message=bridge.hashed_fingerprint,
|
||||
section="bridge",
|
||||
form=form)
|
||||
|
||||
|
||||
def response_404(message: str):
|
||||
return Response(render_template("error.html.j2",
|
||||
header="404 Not Found",
|
||||
message=message))
|
||||
|
||||
|
||||
def view_lifecycle(*,
|
||||
header: str,
|
||||
message: str,
|
||||
success_message: str,
|
||||
success_view: str,
|
||||
section: str,
|
||||
resource: AbstractResource,
|
||||
action: str):
|
||||
form = LifecycleForm()
|
||||
if form.validate_on_submit():
|
||||
if action == "destroy":
|
||||
resource.destroy()
|
||||
elif action == "deprecate":
|
||||
resource.deprecate()
|
||||
flash(success_message, "success")
|
||||
return redirect(url_for(success_view))
|
||||
return render_template("lifecycle.html.j2",
|
||||
header=header,
|
||||
message=message,
|
||||
section=section,
|
||||
form=form)
|
||||
|
||||
|
||||
@portal.route("/bridgeconf/destroy/<bridgeconf_id>", methods=['GET', 'POST'])
|
||||
def destroy_bridgeconf(bridgeconf_id: int):
|
||||
bridgeconf = BridgeConf.query.filter(BridgeConf.id == bridgeconf_id, BridgeConf.destroyed == None).first()
|
||||
if bridgeconf is None:
|
||||
return response_404("The requested bridge configuration could not be found.")
|
||||
return view_lifecycle(
|
||||
header=f"Destroy bridge configuration?",
|
||||
message=bridgeconf.description,
|
||||
success_view="portal.view_bridgeconfs",
|
||||
success_message="All bridges from the destroyed configuration will shortly be destroyed at their providers.",
|
||||
section="bridgeconf",
|
||||
resource=bridgeconf,
|
||||
action="destroy"
|
||||
)
|
||||
|
||||
|
||||
@portal.route("/origin/destroy/<origin_id>", methods=['GET', 'POST'])
|
||||
def destroy_origin(origin_id: int):
|
||||
origin = Origin.query.filter(Origin.id == origin_id, Origin.destroyed == None).first()
|
||||
if origin is None:
|
||||
return response_404("The requested origin could not be found.")
|
||||
return view_lifecycle(
|
||||
header=f"Destroy origin {origin.domain_name}",
|
||||
message=origin.description,
|
||||
success_message="All proxies from the destroyed origin will shortly be destroyed at their providers.",
|
||||
success_view="portal.view_origins",
|
||||
section="origin",
|
||||
resource=origin,
|
||||
action="destroy"
|
||||
)
|
69
app/portal/forms.py
Normal file
69
app/portal/forms.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, SelectField, BooleanField, IntegerField
|
||||
from wtforms.validators import DataRequired, NumberRange
|
||||
|
||||
|
||||
class NewGroupForm(FlaskForm):
|
||||
group_name = StringField("Short Name", validators=[DataRequired()])
|
||||
description = StringField("Description", validators=[DataRequired()])
|
||||
eotk = BooleanField("Deploy EOTK instances?")
|
||||
submit = SubmitField('Save Changes', render_kw={"class": "btn btn-success"})
|
||||
|
||||
|
||||
class EditGroupForm(FlaskForm):
|
||||
description = StringField('Description', validators=[DataRequired()])
|
||||
eotk = BooleanField("Deploy EOTK instances?")
|
||||
submit = SubmitField('Save Changes', render_kw={"class": "btn btn-success"})
|
||||
|
||||
|
||||
class NewOriginForm(FlaskForm):
|
||||
domain_name = StringField('Domain Name', validators=[DataRequired()])
|
||||
description = StringField('Description', validators=[DataRequired()])
|
||||
group = SelectField('Group', validators=[DataRequired()])
|
||||
submit = SubmitField('Save Changes')
|
||||
|
||||
|
||||
class EditOriginForm(FlaskForm):
|
||||
description = StringField('Description', validators=[DataRequired()])
|
||||
group = SelectField('Group', validators=[DataRequired()])
|
||||
submit = SubmitField('Save Changes')
|
||||
|
||||
|
||||
class EditMirrorForm(FlaskForm):
|
||||
origin = SelectField('Origin')
|
||||
url = StringField('URL')
|
||||
submit = SubmitField('Save Changes')
|
||||
|
||||
|
||||
class EditProxyForm(FlaskForm):
|
||||
origin = SelectField('Origin')
|
||||
submit = SubmitField('Save Changes')
|
||||
|
||||
|
||||
class LifecycleForm(FlaskForm):
|
||||
submit = SubmitField('Confirm')
|
||||
|
||||
|
||||
class NewBridgeConfForm(FlaskForm):
|
||||
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")])
|
||||
submit = SubmitField('Save Changes')
|
||||
|
||||
|
||||
class EditBridgeConfForm(FlaskForm):
|
||||
description = StringField('Description')
|
||||
number = IntegerField('Number', validators=[NumberRange(1, message="One or more bridges must be created")])
|
||||
submit = SubmitField('Save Changes')
|
||||
|
||||
|
||||
class NewMirrorListForm(FlaskForm):
|
||||
provider = SelectField('Provider', validators=[DataRequired()])
|
||||
format = SelectField('Distribution Method', validators=[DataRequired()])
|
||||
description = StringField('Description', validators=[DataRequired()])
|
||||
container = StringField('Container', validators=[DataRequired()])
|
||||
branch = StringField('Branch')
|
||||
filename = StringField('Filename', validators=[DataRequired()])
|
||||
submit = SubmitField('Save Changes')
|
100
app/portal/static/portal.css
Normal file
100
app/portal/static/portal.css
Normal file
|
@ -0,0 +1,100 @@
|
|||
body {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.feather {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sidebar
|
||||
*/
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
/* rtl:raw:
|
||||
right: 0;
|
||||
*/
|
||||
bottom: 0;
|
||||
/* rtl:remove */
|
||||
left: 0;
|
||||
z-index: 100; /* Behind the navbar */
|
||||
padding: 48px 0 0; /* Height of navbar */
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar {
|
||||
top: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: calc(100vh - 48px);
|
||||
padding-top: .5rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sidebar .nav-link .feather {
|
||||
margin-right: 4px;
|
||||
color: #727272;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #2470dc;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover .feather,
|
||||
.sidebar .nav-link.active .feather {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: .75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/*
|
||||
* Navbar
|
||||
*/
|
||||
|
||||
.navbar-brand {
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
font-size: 1rem;
|
||||
background-color: rgba(0, 0, 0, .25);
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
|
||||
}
|
||||
|
||||
.navbar .navbar-toggler {
|
||||
top: .25rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.navbar .form-control {
|
||||
padding: .75rem 1rem;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.form-control-dark {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, .1);
|
||||
border-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
.form-control-dark:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
|
||||
}
|
31
app/portal/templates/alarms.html.j2
Normal file
31
app/portal/templates/alarms.html.j2
Normal file
|
@ -0,0 +1,31 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Alarms</h1>
|
||||
<h2 class="h3">Proxies</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Resource</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for alarm in alarms %}
|
||||
<tr class="bg-{% if alarm.alarm_state.name == "OK" %}success{% elif alarm.alarm_state.name == "UNKNOWN" %}dark{% else %}danger{% endif %} text-light">
|
||||
{% if alarm.target == "proxy" %}
|
||||
<td>Proxy: {{ alarm.proxy.url }} ({{ alarm.proxy.origin.domain_name }})</td>
|
||||
{% elif alarm.target == "service/cloudfront" %}
|
||||
<td>AWS CloudFront</td>
|
||||
{% endif %}
|
||||
<td>{{ alarm.alarm_type }}</td>
|
||||
<td>{{ alarm.alarm_state.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
162
app/portal/templates/base.html.j2
Normal file
162
app/portal/templates/base.html.j2
Normal file
|
@ -0,0 +1,162 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% block head %}
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="generator" content="Bypass Censorship Portal">
|
||||
|
||||
{% block styles %}
|
||||
<!-- Bootstrap CSS -->
|
||||
{{ bootstrap.load_css() }}
|
||||
{% endblock %}
|
||||
|
||||
<title>Bypass Censorship Portal</title>
|
||||
|
||||
<style>
|
||||
.bd-placeholder-img {
|
||||
font-size: 1.125rem;
|
||||
text-anchor: middle;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bd-placeholder-img-lg {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Custom styles for this template -->
|
||||
<link href="/portal/static/portal.css" rel="stylesheet">
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
|
||||
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="#">Bypass Censorship</a>
|
||||
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<form class="w-100" action="{{ url_for("portal.search") }}">
|
||||
<input class="form-control form-control-dark w-100" type="text" name="query" placeholder="Search"
|
||||
aria-label="Search">
|
||||
</form>
|
||||
<div class="navbar-nav">
|
||||
<div class="nav-item text-nowrap">
|
||||
<a class="nav-link px-3" href="#">{{ request.headers.get('X-User-Name', 'Default User') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
|
||||
<div class="position-sticky pt-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if section == "home" %} active{% endif %}"
|
||||
href="{{ url_for("portal.portal_home") }}">
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
||||
<span>Configuration</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if section == "group" %} active{% endif %}"
|
||||
href="{{ url_for("portal.view_groups") }}">
|
||||
Groups
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if section == "origin" %} active{% endif %}"
|
||||
href="{{ url_for("portal.view_origins") }}">
|
||||
Origins
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if section == "list" %} active{% endif %}"
|
||||
href="{{ url_for("portal.view_mirror_lists") }}">
|
||||
Mirror Lists
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if section == "bridgeconf" %} active{% endif %}"
|
||||
href="{{ url_for("portal.view_bridgeconfs") }}">
|
||||
Tor Bridges
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
||||
<span>Infrastructure</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="disabled nav-link{% if section == "eotk" %} active{% endif %}"
|
||||
href="#">
|
||||
EOTK Instances
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if section == "proxy" %} active{% endif %}"
|
||||
href="{{ url_for("portal.view_proxies") }}">
|
||||
Proxies
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if section == "bridge" %} active{% endif %}"
|
||||
href="{{ url_for("portal.view_bridges") }}">
|
||||
Tor Bridges
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
|
||||
<span>Monitoring</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if section == "alarm" %} active{% endif %}"
|
||||
href="{{ url_for("portal.view_alarms") }}">
|
||||
Alarms
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} mt-2">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
<!-- Optional JavaScript -->
|
||||
{{ bootstrap.load_js() }}
|
||||
{% endblock %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js"
|
||||
integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE"
|
||||
crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
16
app/portal/templates/bridgeconf.html.j2
Normal file
16
app/portal/templates/bridgeconf.html.j2
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
{% from "tables.html.j2" import bridges_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Tor Bridge Configuration</h1>
|
||||
<h2 class="h3">{{ bridgeconf.group.group_name }}: {{ bridgeconf.provider }}/{{ bridgeconf.method }}</h2>
|
||||
|
||||
<div style="border: 1px solid #666;" class="p-3">
|
||||
{{ render_form(form) }}
|
||||
</div>
|
||||
|
||||
<h3>Bridges</h3>
|
||||
{{ bridges_table(bridgeconf.bridges) }}
|
||||
|
||||
{% endblock %}
|
8
app/portal/templates/bridgeconfs.html.j2
Normal file
8
app/portal/templates/bridgeconfs.html.j2
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from "tables.html.j2" import bridgeconfs_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Tor Bridge Configurations</h1>
|
||||
<a href="{{ url_for("portal.new_bridgeconf") }}" class="btn btn-success">Create new configuration</a>
|
||||
{{ bridgeconfs_table(bridgeconfs) }}
|
||||
{% endblock %}
|
7
app/portal/templates/bridges.html.j2
Normal file
7
app/portal/templates/bridges.html.j2
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from "tables.html.j2" import bridges_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Tor Bridges</h1>
|
||||
{{ bridges_table(bridges) }}
|
||||
{% endblock %}
|
6
app/portal/templates/error.html.j2
Normal file
6
app/portal/templates/error.html.j2
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% extends "base.html.j2" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">{{ header }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
{% endblock %}
|
18
app/portal/templates/group.html.j2
Normal file
18
app/portal/templates/group.html.j2
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
{% from "tables.html.j2" import origins_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Groups</h1>
|
||||
<h2 class="h3">{{ group.group_name }}</h2>
|
||||
|
||||
<div style="border: 1px solid #666;" class="p-3">
|
||||
{{ render_form(form) }}
|
||||
</div>
|
||||
|
||||
<h3 class="mt-3">Origins</h3>
|
||||
<a href="{{ url_for("portal.new_origin", group_id=group.id) }}" class="btn btn-success btn-sm">Create new origin</a>
|
||||
{% if group.origins %}
|
||||
{{ origins_table(group.origins) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
30
app/portal/templates/groups.html.j2
Normal file
30
app/portal/templates/groups.html.j2
Normal file
|
@ -0,0 +1,30 @@
|
|||
{% extends "base.html.j2" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Groups</h1>
|
||||
<a href="{{ url_for("portal.new_group") }}" class="btn btn-success">Create new group</a>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">EOTK</th>
|
||||
<th scope="col">Sites</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for group in groups %}
|
||||
<tr>
|
||||
<td>{{ group.group_name }}</td>
|
||||
<td>{{ group.description }}</td>
|
||||
<td>{% if group.eotk %}√{% else %}x{% endif %}</td>
|
||||
<td>{{ group.origins | length }}</td>
|
||||
<td><a href="{{ url_for("portal.edit_group", group_id=group.id) }}" class="btn btn-primary btn-sm">View/Edit</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
6
app/portal/templates/home.html.j2
Normal file
6
app/portal/templates/home.html.j2
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% extends "base.html.j2" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Welcome</h1>
|
||||
<p>Welcome to the Bypass Censorship portal.</p>
|
||||
{% endblock %}
|
8
app/portal/templates/lifecycle.html.j2
Normal file
8
app/portal/templates/lifecycle.html.j2
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">{{ header }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
{{ render_form(form) }}
|
||||
{% endblock %}
|
8
app/portal/templates/mirrorlists.html.j2
Normal file
8
app/portal/templates/mirrorlists.html.j2
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from "tables.html.j2" import mirrorlists_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Mirror Lists</h1>
|
||||
<a href="{{ url_for("portal.new_mirror_list") }}" class="btn btn-success">Create new mirror list</a>
|
||||
{{ mirrorlists_table(mirrorlists) }}
|
||||
{% endblock %}
|
12
app/portal/templates/new.html.j2
Normal file
12
app/portal/templates/new.html.j2
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">{{ (resource_p or (section + "s")).title() }}</h1>
|
||||
<h2 class="h3">New {{ section.lower() }}</h2>
|
||||
|
||||
<div style="border: 1px solid #666;" class="p-3">
|
||||
{{ render_form(form) }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
20
app/portal/templates/origin.html.j2
Normal file
20
app/portal/templates/origin.html.j2
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
{% from "tables.html.j2" import proxies_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Origins</h1>
|
||||
<h2 class="h3">
|
||||
{{ origin.group.group_name }}: {{ origin.domain_name }}
|
||||
<a href="{{ origin.domain_name }}" class="btn btn-secondary btn-sm" target="_bypass"
|
||||
rel="noopener noreferer">⎋</a>
|
||||
</h2>
|
||||
|
||||
<div style="border: 1px solid #666;" class="p-3">
|
||||
{{ render_form(form) }}
|
||||
</div>
|
||||
|
||||
<h3>Proxies</h3>
|
||||
{{ proxies_table(origin.proxies) }}
|
||||
|
||||
{% endblock %}
|
8
app/portal/templates/origins.html.j2
Normal file
8
app/portal/templates/origins.html.j2
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from "tables.html.j2" import origins_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Origins</h1>
|
||||
<a href="{{ url_for("portal.new_origin") }}" class="btn btn-success">Create new origin</a>
|
||||
{{ origins_table(origins) }}
|
||||
{% endblock %}
|
7
app/portal/templates/proxies.html.j2
Normal file
7
app/portal/templates/proxies.html.j2
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from "tables.html.j2" import proxies_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Proxies</h1>
|
||||
{{ proxies_table(proxies) }}
|
||||
{% endblock %}
|
14
app/portal/templates/search.html.j2
Normal file
14
app/portal/templates/search.html.j2
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html.j2" %}
|
||||
{% from "tables.html.j2" import origins_table %}
|
||||
{% from "tables.html.j2" import proxies_table %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h2 mt-3">Search Results</h1>
|
||||
{% if origins %}
|
||||
<h2 class="h3">Origins</h2>
|
||||
{{ origins_table(origins) }}
|
||||
{% endif %}{% if proxies %}
|
||||
<h2 class="h3">Proxies</h2>
|
||||
{{ proxies_table(proxies) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
253
app/portal/templates/tables.html.j2
Normal file
253
app/portal/templates/tables.html.j2
Normal file
|
@ -0,0 +1,253 @@
|
|||
{% macro origins_table(origins) %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">Mirrors</th>
|
||||
<th scope="col">Proxies</th>
|
||||
<th scope="col">Group</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for origin in origins %}
|
||||
{% if not origin.destroyed %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://{{ origin.domain_name }}" target="_bypass" rel="noopener noreferer"
|
||||
class="btn btn-secondary btn-sm">⎋</a>
|
||||
{{ origin.domain_name }}
|
||||
</td>
|
||||
<td>{{ origin.description }}</td>
|
||||
<td>{{ origin.mirrors | length }}</td>
|
||||
<td>{{ origin.proxies | length }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for("portal.edit_group", group_id=origin.group.id) }}">{{ origin.group.group_name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for("portal.edit_origin", origin_id=origin.id) }}"
|
||||
class="btn btn-primary btn-sm">View/Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro proxies_table(proxies) %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Origin Domain Name</th>
|
||||
<th scope="col">Group</th>
|
||||
<th scope="col">Provider</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col">Alarms</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for proxy in proxies %}
|
||||
{% if not proxy.destroyed %}
|
||||
<tr class="align-middle{% if proxy.deprecated %} bg-warning{% endif %}">
|
||||
<td>
|
||||
<a href="https://{{ proxy.origin.domain_name }}" class="btn btn-secondary btn-sm"
|
||||
target="_bypass"
|
||||
rel="noopener noreferer">⎋</a>
|
||||
<a href="{{ url_for("portal.edit_origin", origin_id=proxy.origin.id) }}">{{ proxy.origin.domain_name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for("portal.edit_group", group_id=proxy.origin.group.id) }}">{{ proxy.origin.group.group_name }}</a>
|
||||
</td>
|
||||
<td>{{ proxy.provider }}</td>
|
||||
<td>
|
||||
<a href="{{ proxy.url }}" class="btn btn-secondary btn-sm" target="_bypass"
|
||||
rel="noopener noreferer">⎋</a>
|
||||
<span{% if proxy.deprecated %}
|
||||
class="text-decoration-line-through"{% endif %}>{{ proxy.url }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% for alarm in proxy.alarms %}
|
||||
<span title="{{ alarm.alarm_type }}">
|
||||
{% if alarm.alarm_state.name == "OK" %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-check-circle text-success" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
|
||||
</svg>
|
||||
{% elif alarm.alarm_state.name == "UNKNOWN" %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-question-circle text-muted" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-exclamation-circle text-danger" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if proxy.deprecated %}
|
||||
<a href="#" class="disabled btn btn-sm btn-outline-dark">Expiring
|
||||
in {{ proxy.deprecated | mirror_expiry }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for("portal.blocked_proxy", proxy_id=proxy.id) }}"
|
||||
class="btn btn-warning btn-sm">Mark blocked</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro bridgeconfs_table(bridgeconfs) %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Group</th>
|
||||
<th scope="col">Provider</th>
|
||||
<th scope="col">Distribution Method</th>
|
||||
<th scope="col">Number</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for bridgeconf in bridgeconfs %}
|
||||
{% if not bridgeconf.destroyed %}
|
||||
<tr class="align-middle">
|
||||
<td>
|
||||
<a href="{{ url_for("portal.edit_group", group_id=bridgeconf.group.id) }}">{{ bridgeconf.group.group_name }}</a>
|
||||
</td>
|
||||
<td>{{ bridgeconf.provider }}</td>
|
||||
<td>{{ bridgeconf.method }}</td>
|
||||
<td>{{ bridgeconf.number }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for("portal.edit_bridgeconf", bridgeconf_id=bridgeconf.id) }}"
|
||||
class="btn btn-primary btn-sm">View/Edit</a>
|
||||
<a href="{{ url_for("portal.destroy_bridgeconf", bridgeconf_id=bridgeconf.id) }}"
|
||||
class="btn btn-danger btn-sm">Destroy</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro bridges_table(bridges) %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Group</th>
|
||||
<th scope="col">Configuration</th>
|
||||
<th scope="col">Nickname</th>
|
||||
<th scope="col">Hashed Fingerprint</th>
|
||||
<th scope="col">Alarms</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for bridge in bridges %}
|
||||
{% if not bridge.destroyed %}
|
||||
<tr class="align-middle{% if bridge.deprecated %} bg-warning{% endif %}">
|
||||
<td>
|
||||
<a href="{{ url_for("portal.edit_group", group_id=bridge.conf.group.id) }}">{{ bridge.conf.group.group_name }}</a>
|
||||
</td>
|
||||
<td>{{ bridge.conf.description }} ({{ bridge.conf.provider }}/{{ bridge.conf.method }})</td>
|
||||
<td>
|
||||
<a href="https://metrics.torproject.org/rs.html#details/{{ bridge.hashed_fingerprint }}">
|
||||
{{ bridge.nickname }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ bridge.hashed_fingerprint }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{% for alarm in bridge.alarms %}
|
||||
<span title="{{ alarm.alarm_type }}">
|
||||
{% if alarm.alarm_state.name == "OK" %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-check-circle text-success" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
|
||||
</svg>
|
||||
{% elif alarm.alarm_state.name == "UNKNOWN" %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-question-circle text-muted" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-exclamation-circle text-danger" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if bridge.deprecated %}
|
||||
<a href="#" class="disabled btn btn-sm btn-outline-dark">Expiring
|
||||
in {{ bridge.deprecated | mirror_expiry }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for("portal.blocked_bridge", bridge_id=bridge.id) }}"
|
||||
class="btn btn-warning btn-sm">Mark blocked</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro mirrorlists_table(mirrorlists) %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Provider</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">URI</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for list in mirrorlists %}
|
||||
{% if not list.destroyed %}
|
||||
<tr class="align-middle">
|
||||
<td>{{ list.provider }}</td>
|
||||
<td>{{ list.format }}</td>
|
||||
<td>{{ list.url() }}</td>
|
||||
<td>{{ list.description }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for("portal.destroy_mirror_list", list_id=list.id) }}"
|
||||
class="btn btn-danger btn-sm">Destroy</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
0
app/static/.gitkeep
Normal file
0
app/static/.gitkeep
Normal file
56
app/terraform/__init__.py
Normal file
56
app/terraform/__init__.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Dict, Any
|
||||
|
||||
import jinja2
|
||||
|
||||
from app import app
|
||||
|
||||
|
||||
class BaseAutomation:
|
||||
short_name = None
|
||||
|
||||
def working_directory(self, filename=None):
|
||||
return os.path.join(
|
||||
app.config['TERRAFORM_DIRECTORY'],
|
||||
self.short_name or self.__class__.__name__.lower(),
|
||||
filename or ""
|
||||
)
|
||||
|
||||
def write_terraform_config(self, template: str, **kwargs):
|
||||
tmpl = jinja2.Template(template)
|
||||
with open(self.working_directory("main.tf"), 'w') as tf:
|
||||
tf.write(tmpl.render(**kwargs))
|
||||
|
||||
def terraform_init(self):
|
||||
subprocess.run(
|
||||
['terraform', 'init'],
|
||||
cwd=self.working_directory())
|
||||
|
||||
def terraform_plan(self):
|
||||
plan = subprocess.run(
|
||||
['terraform', 'plan'],
|
||||
cwd=self.working_directory())
|
||||
|
||||
def terraform_apply(self, refresh: bool = True, parallelism: int = 10):
|
||||
subprocess.run(
|
||||
['terraform', 'apply', f'-refresh={str(refresh).lower()}', '-auto-approve',
|
||||
f'-parallelism={str(parallelism)}'],
|
||||
cwd=self.working_directory())
|
||||
|
||||
def terraform_show(self) -> Dict[str, Any]:
|
||||
terraform = subprocess.run(
|
||||
['terraform', 'show', '-json'],
|
||||
cwd=os.path.join(
|
||||
self.working_directory()),
|
||||
stdout=subprocess.PIPE)
|
||||
return json.loads(terraform.stdout)
|
||||
|
||||
def terraform_output(self) -> Dict[str, Any]:
|
||||
terraform = subprocess.run(
|
||||
['terraform', 'output', '-json'],
|
||||
cwd=os.path.join(
|
||||
self.working_directory()),
|
||||
stdout=subprocess.PIPE)
|
||||
return json.loads(terraform.stdout)
|
29
app/terraform/block_bridge_github.py
Normal file
29
app/terraform/block_bridge_github.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
import datetime
|
||||
|
||||
from dateutil.parser import isoparse
|
||||
from github import Github
|
||||
|
||||
from app import app
|
||||
from app.models import Bridge
|
||||
|
||||
|
||||
def check_blocks():
|
||||
g = Github(app.config['GITHUB_API_KEY'])
|
||||
repo = g.get_repo(app.config['GITHUB_BRIDGE_REPO'])
|
||||
for vp in app.config['GITHUB_BRIDGE_VANTAGE_POINTS']:
|
||||
results = repo.get_contents(f"recentResult_{vp}").decoded_content.decode('utf-8').splitlines()
|
||||
for result in results:
|
||||
parts = result.split("\t")
|
||||
if isoparse(parts[2]) < (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=3)):
|
||||
continue
|
||||
if int(parts[1]) < 40:
|
||||
bridge = Bridge.query.filter(
|
||||
Bridge.nickname == parts[0]
|
||||
).first()
|
||||
if bridge is not None:
|
||||
bridge.deprecate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
check_blocks()
|
67
app/terraform/block_external.py
Normal file
67
app/terraform/block_external.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
|
||||
from app import app
|
||||
from app.models import Proxy
|
||||
|
||||
|
||||
def check_blocks():
|
||||
user_agent = {'User-agent': 'BypassCensorship/1.0 (contact@sr2.uk for info)'}
|
||||
page = requests.get(app.config['EXTERNAL_CHECK_URL'], headers=user_agent)
|
||||
soup = BeautifulSoup(page.content, 'html.parser')
|
||||
h2 = soup.find_all('h2')
|
||||
div = soup.find_all('div', class_="overflow-auto mb-5")
|
||||
|
||||
results = {}
|
||||
|
||||
i = 0
|
||||
while i < len(h2):
|
||||
if not div[i].div:
|
||||
urls = []
|
||||
a = div[i].find_all('a')
|
||||
j = 0
|
||||
while j < len(a):
|
||||
urls.append(a[j].text)
|
||||
j += 1
|
||||
results[h2[i].text] = urls
|
||||
else:
|
||||
results[h2[i].text] = []
|
||||
i += 1
|
||||
|
||||
for vp in results:
|
||||
if vp not in app.config['EXTERNAL_VANTAGE_POINTS']:
|
||||
continue
|
||||
for url in results[vp]:
|
||||
if "cloudfront.net" in url:
|
||||
slug = url[len('https://'):][:-len('.cloudfront.net')]
|
||||
print(f"Found {slug} blocked")
|
||||
proxy = Proxy.query.filter(
|
||||
Proxy.provider == "cloudfront",
|
||||
Proxy.slug == slug
|
||||
).first()
|
||||
if not proxy:
|
||||
print("Proxy not found")
|
||||
continue
|
||||
if proxy.deprecated:
|
||||
print("Proxy already marked blocked")
|
||||
continue
|
||||
proxy.deprecate()
|
||||
if "azureedge.net" in url:
|
||||
slug = url[len('https://'):][:-len('.azureedge.net')]
|
||||
print(f"Found {slug} blocked")
|
||||
proxy = Proxy.query.filter(
|
||||
Proxy.provider == "azure_cdn",
|
||||
Proxy.slug == slug
|
||||
).first()
|
||||
if not proxy:
|
||||
print("Proxy not found")
|
||||
continue
|
||||
if proxy.deprecated:
|
||||
print("Proxy already marked blocked")
|
||||
continue
|
||||
proxy.deprecate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
check_blocks()
|
77
app/terraform/bridge/__init__.py
Normal file
77
app/terraform/bridge/__init__.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
import datetime
|
||||
|
||||
from app import app
|
||||
from app.extensions import db
|
||||
from app.models import BridgeConf, Bridge, Group
|
||||
from app.terraform import BaseAutomation
|
||||
|
||||
|
||||
class BridgeAutomation(BaseAutomation):
|
||||
def create_missing(self):
|
||||
bridgeconfs = BridgeConf.query.filter(
|
||||
BridgeConf.provider == self.provider
|
||||
).all()
|
||||
for bridgeconf in bridgeconfs:
|
||||
active_bridges = Bridge.query.filter(
|
||||
Bridge.conf_id == bridgeconf.id,
|
||||
Bridge.deprecated == None
|
||||
).all()
|
||||
if len(active_bridges) < bridgeconf.number:
|
||||
for i 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()
|
||||
active_bridge_count -= 1
|
||||
if active_bridge_count == bridgeconf.number:
|
||||
break
|
||||
|
||||
def destroy_expired(self):
|
||||
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=0)
|
||||
bridges = [b for b in Bridge.query.filter(
|
||||
Bridge.destroyed == None,
|
||||
Bridge.deprecated < cutoff
|
||||
).all() if b.conf.provider == self.provider]
|
||||
for bridge in bridges:
|
||||
bridge.destroy()
|
||||
|
||||
def generate_terraform(self):
|
||||
self.write_terraform_config(
|
||||
self.template,
|
||||
groups=Group.query.all(),
|
||||
bridgeconfs=BridgeConf.query.filter(
|
||||
BridgeConf.destroyed == None,
|
||||
BridgeConf.provider == self.provider
|
||||
).all(),
|
||||
global_namespace=app.config['GLOBAL_NAMESPACE'],
|
||||
**{
|
||||
k: app.config[k.upper()]
|
||||
for k in self.template_parameters
|
||||
}
|
||||
)
|
||||
|
||||
def import_terraform(self):
|
||||
outputs = self.terraform_output()
|
||||
for output in outputs:
|
||||
if output.startswith('bridge_hashed_fingerprint_'):
|
||||
parts = outputs[output]['value'].split(" ")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
bridge = Bridge.query.filter(Bridge.id == output[len('bridge_hashed_fingerprint_'):]).first()
|
||||
bridge.nickname = parts[0]
|
||||
bridge.hashed_fingerprint = parts[1]
|
||||
bridge.terraform_updated = datetime.datetime.utcnow()
|
||||
if output.startswith('bridge_bridgeline_'):
|
||||
parts = outputs[output]['value'].split(" ")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
bridge = Bridge.query.filter(Bridge.id == output[len('bridge_bridgeline_'):]).first()
|
||||
del(parts[3])
|
||||
bridge.bridgeline = " ".join(parts)
|
||||
bridge.terraform_updated = datetime.datetime.utcnow()
|
||||
db.session.commit()
|
84
app/terraform/bridge/aws.py
Normal file
84
app/terraform/bridge/aws.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
from app import app
|
||||
from app.terraform.bridge import BridgeAutomation
|
||||
|
||||
|
||||
class BridgeAWSAutomation(BridgeAutomation):
|
||||
short_name = "bridge_aws"
|
||||
provider = "aws"
|
||||
|
||||
template_parameters = [
|
||||
"aws_access_key",
|
||||
"aws_secret_key",
|
||||
"ssh_public_key_path"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
version = "~> 4.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
access_key = "{{ aws_access_key }}"
|
||||
secret_key = "{{ aws_secret_key }}"
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
locals {
|
||||
ssh_key = file("{{ ssh_public_key_path }}")
|
||||
}
|
||||
|
||||
{% for group in groups %}
|
||||
module "label_{{ group.id }}" {
|
||||
source = "cloudposse/label/null"
|
||||
version = "0.25.0"
|
||||
namespace = "{{ global_namespace }}"
|
||||
tenant = "{{ group.group_name }}"
|
||||
label_order = ["namespace", "tenant", "name", "attributes"]
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
{% for bridgeconf in bridgeconfs %}
|
||||
{% for bridge in bridgeconf.bridges %}
|
||||
{% if not bridge.destroyed %}
|
||||
module "bridge_{{ bridge.id }}" {
|
||||
source = "sr2c/tor-bridge/aws"
|
||||
version = "0.0.1"
|
||||
ssh_key = local.ssh_key
|
||||
contact_info = "hi"
|
||||
context = module.label_{{ bridgeconf.group.id }}.context
|
||||
name = "br"
|
||||
attributes = ["{{ bridge.id }}"]
|
||||
distribution_method = "{{ bridge.conf.method }}"
|
||||
}
|
||||
|
||||
output "bridge_hashed_fingerprint_{{ bridge.id }}" {
|
||||
value = module.bridge_{{ bridge.id }}.hashed_fingerprint
|
||||
}
|
||||
|
||||
output "bridge_bridgeline_{{ bridge.id }}" {
|
||||
value = module.bridge_{{ bridge.id }}.bridgeline
|
||||
sensitive = true
|
||||
}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
def automate():
|
||||
auto = BridgeAWSAutomation()
|
||||
auto.destroy_expired()
|
||||
auto.create_missing()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
||||
auto.import_terraform()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
automate()
|
95
app/terraform/bridge/gandi.py
Normal file
95
app/terraform/bridge/gandi.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
from app import app
|
||||
from app.terraform.bridge import BridgeAutomation
|
||||
|
||||
|
||||
class BridgeGandiAutomation(BridgeAutomation):
|
||||
short_name = "bridge_gandi"
|
||||
provider = "gandi"
|
||||
|
||||
template_parameters = [
|
||||
"gandi_openstack_user",
|
||||
"gandi_openstack_password",
|
||||
"gandi_openstack_tenant_name",
|
||||
"ssh_public_key_path"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
openstack = {
|
||||
source = "terraform-provider-openstack/openstack"
|
||||
version = "~> 1.42.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "openstack" {
|
||||
auth_url = "https://keystone.sd6.api.gandi.net:5000/v3"
|
||||
user_domain_name = "public"
|
||||
project_domain_name = "public"
|
||||
user_name = "{{ gandi_openstack_user }}"
|
||||
password = "{{ gandi_openstack_password }}"
|
||||
tenant_name = "{{ gandi_openstack_tenant_name }}"
|
||||
region = "FR-SD6"
|
||||
}
|
||||
|
||||
locals {
|
||||
ssh_key = file("{{ ssh_public_key_path }}")
|
||||
}
|
||||
|
||||
{% for group in groups %}
|
||||
module "label_{{ group.id }}" {
|
||||
source = "cloudposse/label/null"
|
||||
version = "0.25.0"
|
||||
namespace = "{{ global_namespace }}"
|
||||
tenant = "{{ group.group_name }}"
|
||||
label_order = ["namespace", "tenant", "name", "attributes"]
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
{% for bridgeconf in bridgeconfs %}
|
||||
{% for bridge in bridgeconf.bridges %}
|
||||
{% if not bridge.destroyed %}
|
||||
module "bridge_{{ bridge.id }}" {
|
||||
source = "sr2c/tor-bridge/openstack"
|
||||
version = "0.0.6"
|
||||
context = module.label_{{ bridgeconf.group.id }}.context
|
||||
name = "br"
|
||||
attributes = ["{{ bridge.id }}"]
|
||||
ssh_key = local.ssh_key
|
||||
contact_info = "hi"
|
||||
distribution_method = "{{ bridge.conf.method }}"
|
||||
|
||||
image_name = "Debian 11 Bullseye"
|
||||
flavor_name = "V-R1"
|
||||
external_network_name = "public"
|
||||
require_block_device_creation = true
|
||||
}
|
||||
|
||||
output "bridge_hashed_fingerprint_{{ bridge.id }}" {
|
||||
value = module.bridge_{{ bridge.id }}.hashed_fingerprint
|
||||
}
|
||||
|
||||
output "bridge_bridgeline_{{ bridge.id }}" {
|
||||
value = module.bridge_{{ bridge.id }}.bridgeline
|
||||
sensitive = true
|
||||
}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
def automate():
|
||||
auto = BridgeGandiAutomation()
|
||||
auto.destroy_expired()
|
||||
auto.create_missing()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
||||
auto.import_terraform()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
automate()
|
98
app/terraform/bridge/hcloud.py
Normal file
98
app/terraform/bridge/hcloud.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
from app import app
|
||||
from app.terraform.bridge import BridgeAutomation
|
||||
|
||||
|
||||
class BridgeHcloudAutomation(BridgeAutomation):
|
||||
short_name = "bridge_hcloud"
|
||||
provider = "hcloud"
|
||||
|
||||
template_parameters = [
|
||||
"hcloud_token"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = "3.1.0"
|
||||
}
|
||||
hcloud = {
|
||||
source = "hetznercloud/hcloud"
|
||||
version = "1.31.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "hcloud" {
|
||||
token = "{{ hcloud_token }}"
|
||||
}
|
||||
|
||||
data "hcloud_datacenters" "ds" {
|
||||
}
|
||||
|
||||
data "hcloud_server_type" "cx11" {
|
||||
name = "cx11"
|
||||
}
|
||||
|
||||
{% for group in groups %}
|
||||
module "label_{{ group.id }}" {
|
||||
source = "cloudposse/label/null"
|
||||
version = "0.25.0"
|
||||
namespace = "{{ global_namespace }}"
|
||||
tenant = "{{ group.group_name }}"
|
||||
label_order = ["namespace", "tenant", "name", "attributes"]
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
{% for bridgeconf in bridgeconfs %}
|
||||
{% for bridge in bridgeconf.bridges %}
|
||||
{% if not bridge.destroyed %}
|
||||
resource "random_shuffle" "datacenter_{{ bridge.id }}" {
|
||||
input = [for s in data.hcloud_datacenters.ds.datacenters : s.name if contains(s.available_server_type_ids, data.hcloud_server_type.cx11.id)]
|
||||
result_count = 1
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [input] # don't replace all the bridges if a new DC appears
|
||||
}
|
||||
}
|
||||
|
||||
module "bridge_{{ bridge.id }}" {
|
||||
source = "sr2c/tor-bridge/hcloud"
|
||||
version = "0.0.2"
|
||||
datacenter = one(random_shuffle.datacenter_{{ bridge.id }}.result)
|
||||
context = module.label_{{ bridgeconf.group.id }}.context
|
||||
name = "br"
|
||||
attributes = ["{{ bridge.id }}"]
|
||||
ssh_key_name = "bc"
|
||||
contact_info = "hi"
|
||||
distribution_method = "{{ bridge.conf.method }}"
|
||||
}
|
||||
|
||||
output "bridge_hashed_fingerprint_{{ bridge.id }}" {
|
||||
value = module.bridge_{{ bridge.id }}.hashed_fingerprint
|
||||
}
|
||||
|
||||
output "bridge_bridgeline_{{ bridge.id }}" {
|
||||
value = module.bridge_{{ bridge.id }}.bridgeline
|
||||
sensitive = true
|
||||
}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
def automate():
|
||||
auto = BridgeHcloudAutomation()
|
||||
auto.destroy_expired()
|
||||
auto.create_missing()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
||||
auto.import_terraform()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
automate()
|
122
app/terraform/bridge/ovh.py
Normal file
122
app/terraform/bridge/ovh.py
Normal file
|
@ -0,0 +1,122 @@
|
|||
from app import app
|
||||
from app.terraform.bridge import BridgeAutomation
|
||||
|
||||
|
||||
class BridgeOvhAutomation(BridgeAutomation):
|
||||
short_name = "bridge_ovh"
|
||||
provider = "ovh"
|
||||
|
||||
template_parameters = [
|
||||
"ovh_cloud_application_key",
|
||||
"ovh_cloud_application_secret",
|
||||
"ovh_cloud_consumer_key",
|
||||
"ovh_cloud_project_service",
|
||||
"ovh_openstack_user",
|
||||
"ovh_openstack_password",
|
||||
"ovh_openstack_tenant_id",
|
||||
"ssh_public_key_path"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = "3.1.0"
|
||||
}
|
||||
openstack = {
|
||||
source = "terraform-provider-openstack/openstack"
|
||||
version = "~> 1.42.0"
|
||||
}
|
||||
ovh = {
|
||||
source = "ovh/ovh"
|
||||
version = ">= 0.13.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "openstack" {
|
||||
auth_url = "https://auth.cloud.ovh.net/v3/"
|
||||
domain_name = "Default" # Domain name - Always at 'default' for OVHcloud
|
||||
user_name = "{{ ovh_openstack_user }}"
|
||||
password = "{{ ovh_openstack_password }}"
|
||||
tenant_id = "{{ ovh_openstack_tenant_id }}"
|
||||
}
|
||||
|
||||
provider "ovh" {
|
||||
endpoint = "ovh-eu"
|
||||
application_key = "{{ ovh_cloud_application_key }}"
|
||||
application_secret = "{{ ovh_cloud_application_secret }}"
|
||||
consumer_key = "{{ ovh_cloud_consumer_key }}"
|
||||
}
|
||||
|
||||
locals {
|
||||
ssh_key = file("{{ ssh_public_key_path }}")
|
||||
}
|
||||
|
||||
data "ovh_cloud_project_regions" "regions" {
|
||||
service_name = "{{ ovh_cloud_project_service }}"
|
||||
has_services_up = ["instance"]
|
||||
}
|
||||
|
||||
{% for group in groups %}
|
||||
module "label_{{ group.id }}" {
|
||||
source = "cloudposse/label/null"
|
||||
version = "0.25.0"
|
||||
namespace = "{{ global_namespace }}"
|
||||
tenant = "{{ group.group_name }}"
|
||||
label_order = ["namespace", "tenant", "name", "attributes"]
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
{% for bridgeconf in bridgeconfs %}
|
||||
{% for bridge in bridgeconf.bridges %}
|
||||
{% if not bridge.destroyed %}
|
||||
resource "random_shuffle" "region_{{ bridge.id }}" {
|
||||
input = data.ovh_cloud_project_regions.regions.names
|
||||
result_count = 1
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [input] # don't replace all the bridges if a new region appears
|
||||
}
|
||||
}
|
||||
|
||||
module "bridge_{{ bridge.id }}" {
|
||||
source = "sr2c/tor-bridge/openstack"
|
||||
version = "0.0.6"
|
||||
region = one(random_shuffle.region_{{ bridge.id }}.result)
|
||||
context = module.label_{{ bridgeconf.group.id }}.context
|
||||
name = "br"
|
||||
attributes = ["{{ bridge.id }}"]
|
||||
ssh_key = local.ssh_key
|
||||
contact_info = "hi"
|
||||
distribution_method = "{{ bridge.conf.method }}"
|
||||
}
|
||||
|
||||
output "bridge_hashed_fingerprint_{{ bridge.id }}" {
|
||||
value = module.bridge_{{ bridge.id }}.hashed_fingerprint
|
||||
}
|
||||
|
||||
output "bridge_bridgeline_{{ bridge.id }}" {
|
||||
value = module.bridge_{{ bridge.id }}.bridgeline
|
||||
sensitive = true
|
||||
}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
def automate():
|
||||
auto = BridgeOvhAutomation()
|
||||
auto.destroy_expired()
|
||||
auto.create_missing()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
||||
auto.import_terraform()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
automate()
|
79
app/terraform/eotk.py
Normal file
79
app/terraform/eotk.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
from app import app
|
||||
from app.models import Group
|
||||
from app.terraform import BaseAutomation
|
||||
|
||||
|
||||
class EotkAutomation(BaseAutomation):
|
||||
short_name = "eotk"
|
||||
|
||||
template_parameters = [
|
||||
"aws_access_key",
|
||||
"aws_secret_key"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
version = "~> 4.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
access_key = "{{ aws_access_key }}"
|
||||
secret_key = "{{ aws_secret_key }}"
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
{% for group in groups %}
|
||||
module "label_{{ group.id }}" {
|
||||
source = "cloudposse/label/null"
|
||||
version = "0.25.0"
|
||||
namespace = "{{ global_namespace }}"
|
||||
tenant = "{{ group.group_name }}"
|
||||
label_order = ["namespace", "tenant", "name", "attributes"]
|
||||
}
|
||||
|
||||
module "bucket_{{ group.id }}" {
|
||||
source = "cloudposse/s3-bucket/aws"
|
||||
version = "0.49.0"
|
||||
acl = "private"
|
||||
enabled = true
|
||||
user_enabled = true
|
||||
versioning_enabled = false
|
||||
allowed_bucket_actions = [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:ListBucket",
|
||||
"s3:GetBucketLocation"
|
||||
]
|
||||
context = module.label_{{ group.id }}.context
|
||||
name = "logs"
|
||||
attributes = ["eotk"]
|
||||
}
|
||||
|
||||
resource "aws_sns_topic" "alarms_{{ group.id }}" {
|
||||
name = "${module.label_{{ group.id }}.id}-eotk-alarms"
|
||||
}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
def generate_terraform(self):
|
||||
self.write_terraform_config(
|
||||
self.template,
|
||||
groups=Group.query.filter(Group.eotk == True).all(),
|
||||
global_namespace=app.config['GLOBAL_NAMESPACE'],
|
||||
**{
|
||||
k: app.config[k.upper()]
|
||||
for k in self.template_parameters
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
auto = EotkAutomation()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
28
app/terraform/list/__init__.py
Normal file
28
app/terraform/list/__init__.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
import json
|
||||
|
||||
from app import app
|
||||
from app.mirror_sites import bridgelines, mirror_sites, mirror_mapping
|
||||
from app.models import MirrorList
|
||||
from app.terraform import BaseAutomation
|
||||
|
||||
|
||||
class ListAutomation(BaseAutomation):
|
||||
def generate_terraform(self):
|
||||
self.write_terraform_config(
|
||||
self.template,
|
||||
lists=MirrorList.query.filter(
|
||||
MirrorList.destroyed == None,
|
||||
MirrorList.provider == self.provider,
|
||||
).all(),
|
||||
global_namespace=app.config['GLOBAL_NAMESPACE'],
|
||||
**{
|
||||
k: app.config[k.upper()]
|
||||
for k in self.template_parameters
|
||||
}
|
||||
)
|
||||
with open(self.working_directory('bc2.json'), 'w') as out:
|
||||
json.dump(mirror_sites(), out, indent=2, sort_keys=True)
|
||||
with open(self.working_directory('bca.json'), 'w') as out:
|
||||
json.dump(mirror_mapping(), out, indent=2, sort_keys=True)
|
||||
with open(self.working_directory('bridgelines.json'), 'w') as out:
|
||||
json.dump(bridgelines(), out, indent=2, sort_keys=True)
|
55
app/terraform/list/github.py
Normal file
55
app/terraform/list/github.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
from app import app
|
||||
from app.terraform.list import ListAutomation
|
||||
|
||||
|
||||
class ListGithubAutomation(ListAutomation):
|
||||
short_name = "list_github"
|
||||
provider = "github"
|
||||
|
||||
template_parameters = [
|
||||
"github_api_key"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
github = {
|
||||
source = "integrations/github"
|
||||
version = "~> 4.20.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{% for list in lists %}
|
||||
provider "github" {
|
||||
alias = "list_{{ list.id }}"
|
||||
owner = "{{ list.container.split("/")[0] }}"
|
||||
token = "{{ github_api_key }}"
|
||||
}
|
||||
|
||||
data "github_repository" "repository_{{ list.id }}" {
|
||||
provider = github.list_{{ list.id }}
|
||||
name = "{{ list.container.split("/")[1] }}"
|
||||
}
|
||||
|
||||
resource "github_repository_file" "file_{{ list.id }}" {
|
||||
provider = github.list_{{ list.id }}
|
||||
repository = data.github_repository.repository_{{ list.id }}.name
|
||||
branch = "{{ list.branch }}"
|
||||
file = "{{ list.filename }}"
|
||||
content = file("{{ list.format }}.json")
|
||||
commit_message = "Managed by Terraform"
|
||||
commit_author = "Terraform User"
|
||||
commit_email = "terraform@api.otf.is"
|
||||
overwrite_on_create = true
|
||||
}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
auto = ListGithubAutomation()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
54
app/terraform/list/gitlab.py
Normal file
54
app/terraform/list/gitlab.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
from app import app
|
||||
from app.terraform.list import ListAutomation
|
||||
|
||||
|
||||
class ListGitlabAutomation(ListAutomation):
|
||||
short_name = "list_gitlab"
|
||||
provider = "gitlab"
|
||||
|
||||
template_parameters = [
|
||||
"gitlab_token",
|
||||
"gitlab_author_email",
|
||||
"gitlab_author_name",
|
||||
"gitlab_commit_message"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
gitlab = {
|
||||
source = "gitlabhq/gitlab"
|
||||
version = "~> 3.12.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "gitlab" {
|
||||
token = "{{ gitlab_token }}"
|
||||
}
|
||||
|
||||
{% for list in lists %}
|
||||
data "gitlab_project" "project_{{ list.id }}" {
|
||||
id = "{{ list.container }}"
|
||||
}
|
||||
|
||||
resource "gitlab_repository_file" "file_{{ list.id }}" {
|
||||
project = data.gitlab_project.project_{{ list.id }}.id
|
||||
file_path = "{{ list.filename }}"
|
||||
branch = "{{ list.branch }}"
|
||||
content = base64encode(file("{{ list.format }}.json"))
|
||||
author_email = "{{ gitlab_author_email }}"
|
||||
author_name = "{{ gitlab_author_name }}"
|
||||
commit_message = "{{ gitlab_commit_message }}"
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
auto = ListGitlabAutomation()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
46
app/terraform/list/s3.py
Normal file
46
app/terraform/list/s3.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
from app import app
|
||||
from app.terraform.list import ListAutomation
|
||||
|
||||
|
||||
class ListGithubAutomation(ListAutomation):
|
||||
short_name = "list_s3"
|
||||
provider = "s3"
|
||||
|
||||
template_parameters = [
|
||||
"aws_access_key",
|
||||
"aws_secret_key"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
version = "~> 4.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
access_key = "{{ aws_access_key }}"
|
||||
secret_key = "{{ aws_secret_key }}"
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
{% for list in lists %}
|
||||
resource "aws_s3_object" "object_{{ list.id }}" {
|
||||
bucket = "{{ list.container }}"
|
||||
key = "{{ list.filename }}"
|
||||
source = "{{ list.format }}.json"
|
||||
content_type = "application/json"
|
||||
etag = filemd5("{{ list.format }}.json")
|
||||
}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
auto = ListGithubAutomation()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
51
app/terraform/proxy/__init__.py
Normal file
51
app/terraform/proxy/__init__.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
import datetime
|
||||
|
||||
from app import app
|
||||
from app.extensions import db
|
||||
from app.models import Group, Origin, Proxy
|
||||
from app.terraform import BaseAutomation
|
||||
|
||||
|
||||
class ProxyAutomation(BaseAutomation):
|
||||
def create_missing_proxies(self):
|
||||
origins = Origin.query.all()
|
||||
for origin in origins:
|
||||
cloudfront_proxies = [
|
||||
x for x in origin.proxies
|
||||
if x.provider == self.provider and x.deprecated is None and x.destroyed is None
|
||||
]
|
||||
if not cloudfront_proxies:
|
||||
proxy = Proxy()
|
||||
proxy.origin_id = origin.id
|
||||
proxy.provider = self.provider
|
||||
proxy.added = datetime.datetime.utcnow()
|
||||
proxy.updated = datetime.datetime.utcnow()
|
||||
db.session.add(proxy)
|
||||
db.session.commit()
|
||||
|
||||
def destroy_expired_proxies(self):
|
||||
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=3)
|
||||
proxies = Proxy.query.filter(
|
||||
Proxy.destroyed == None,
|
||||
Proxy.provider == self.provider,
|
||||
Proxy.deprecated < cutoff
|
||||
).all()
|
||||
for proxy in proxies:
|
||||
proxy.destroyed = datetime.datetime.utcnow()
|
||||
proxy.updated = datetime.datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def generate_terraform(self):
|
||||
self.write_terraform_config(
|
||||
self.template,
|
||||
groups=Group.query.all(),
|
||||
proxies=Proxy.query.filter(
|
||||
Proxy.provider == self.provider,
|
||||
Proxy.destroyed == None
|
||||
).all(),
|
||||
global_namespace=app.config['GLOBAL_NAMESPACE'],
|
||||
**{
|
||||
k: app.config[k.upper()]
|
||||
for k in self.template_parameters
|
||||
}
|
||||
)
|
238
app/terraform/proxy/azure_cdn.py
Normal file
238
app/terraform/proxy/azure_cdn.py
Normal file
|
@ -0,0 +1,238 @@
|
|||
import datetime
|
||||
import string
|
||||
import random
|
||||
|
||||
from azure.identity import ClientSecretCredential
|
||||
from azure.mgmt.alertsmanagement import AlertsManagementClient
|
||||
import tldextract
|
||||
|
||||
from app import app
|
||||
from app.alarms import get_proxy_alarm
|
||||
from app.extensions import db
|
||||
from app.models import Group, Proxy, Alarm, AlarmState
|
||||
from app.terraform.proxy import ProxyAutomation
|
||||
|
||||
|
||||
class ProxyAzureCdnAutomation(ProxyAutomation):
|
||||
short_name = "proxy_azure_cdn"
|
||||
provider = "azure_cdn"
|
||||
|
||||
template_parameters = [
|
||||
"azure_resource_group_name",
|
||||
"azure_storage_account_name",
|
||||
"azure_location",
|
||||
"azure_client_id",
|
||||
"azure_client_secret",
|
||||
"azure_subscription_id",
|
||||
"azure_tenant_id"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
azurerm = {
|
||||
source = "hashicorp/azurerm"
|
||||
version = "=2.99.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "azurerm" {
|
||||
features {}
|
||||
|
||||
client_id = "{{ azure_client_id }}"
|
||||
client_secret = "{{ azure_client_secret }}"
|
||||
subscription_id = "{{ azure_subscription_id }}"
|
||||
tenant_id = "{{ azure_tenant_id }}"
|
||||
skip_provider_registration = true
|
||||
}
|
||||
|
||||
data "azurerm_resource_group" "this" {
|
||||
name = "{{ azure_resource_group_name }}"
|
||||
}
|
||||
|
||||
resource "azurerm_storage_account" "this" {
|
||||
name = "{{ azure_storage_account_name }}"
|
||||
resource_group_name = data.azurerm_resource_group.this.name
|
||||
location = "{{ azure_location }}"
|
||||
account_tier = "Standard"
|
||||
account_replication_type = "RAGRS"
|
||||
}
|
||||
|
||||
{% for group in groups %}
|
||||
module "label_{{ group.id }}" {
|
||||
source = "cloudposse/label/null"
|
||||
version = "0.25.0"
|
||||
namespace = "{{ global_namespace }}"
|
||||
tenant = "{{ group.group_name }}"
|
||||
label_order = ["namespace", "tenant", "name", "attributes"]
|
||||
}
|
||||
|
||||
resource "azurerm_cdn_profile" "profile_{{ group.id }}" {
|
||||
name = module.label_{{ group.id }}.id
|
||||
location = "{{ azure_location }}"
|
||||
resource_group_name = data.azurerm_resource_group.this.name
|
||||
sku = "Standard_Microsoft"
|
||||
|
||||
tags = module.label_{{ group.id }}.tags
|
||||
}
|
||||
|
||||
resource "azurerm_monitor_diagnostic_setting" "profile_diagnostic_{{ group.id }}" {
|
||||
name = "cdn-diagnostics"
|
||||
target_resource_id = azurerm_cdn_profile.profile_{{ group.id }}.id
|
||||
storage_account_id = azurerm_storage_account.this.id
|
||||
|
||||
log {
|
||||
category = "AzureCDNAccessLog"
|
||||
enabled = true
|
||||
|
||||
retention_policy {
|
||||
enabled = true
|
||||
days = 90
|
||||
}
|
||||
}
|
||||
|
||||
metric {
|
||||
category = "AllMetrics"
|
||||
enabled = true
|
||||
|
||||
retention_policy {
|
||||
enabled = true
|
||||
days = 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "azurerm_monitor_metric_alert" "response_alert_{{ group.id }}" {
|
||||
name = "bandwidth-out-high-${module.label_{{ group.id }}.id}"
|
||||
resource_group_name = data.azurerm_resource_group.this.name
|
||||
scopes = [azurerm_cdn_profile.profile_{{ group.id }}.id]
|
||||
description = "Action will be triggered when response size is too high."
|
||||
|
||||
criteria {
|
||||
metric_namespace = "Microsoft.Cdn/profiles"
|
||||
metric_name = "ResponseSize"
|
||||
aggregation = "Total"
|
||||
operator = "GreaterThan"
|
||||
threshold = 21474836481
|
||||
}
|
||||
|
||||
window_size = "PT1H"
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
{% for proxy in proxies %}
|
||||
resource "azurerm_cdn_endpoint" "endpoint_{{ proxy.id }}" {
|
||||
name = "{{ proxy.slug }}"
|
||||
profile_name = azurerm_cdn_profile.profile_{{ proxy.origin.group.id }}.name
|
||||
location = "{{ azure_location }}"
|
||||
resource_group_name = data.azurerm_resource_group.this.name
|
||||
|
||||
origin {
|
||||
name = "upstream"
|
||||
host_name = "{{ proxy.origin.domain_name }}"
|
||||
}
|
||||
|
||||
global_delivery_rule {
|
||||
modify_request_header_action {
|
||||
action = "Overwrite"
|
||||
name = "User-Agent"
|
||||
value = "Amazon CloudFront"
|
||||
}
|
||||
modify_request_header_action {
|
||||
action = "Append"
|
||||
name = "X-Amz-Cf-Id"
|
||||
value = "dummystring"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "azurerm_monitor_diagnostic_setting" "diagnostic_{{ proxy.id }}" {
|
||||
name = "cdn-diagnostics"
|
||||
target_resource_id = azurerm_cdn_endpoint.endpoint_{{ proxy.id }}.id
|
||||
storage_account_id = azurerm_storage_account.this.id
|
||||
|
||||
log {
|
||||
category = "CoreAnalytics"
|
||||
enabled = true
|
||||
|
||||
retention_policy {
|
||||
enabled = true
|
||||
days = 90
|
||||
}
|
||||
}
|
||||
}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
def create_missing_proxies(self):
|
||||
groups = Group.query.all()
|
||||
for group in groups:
|
||||
active_proxies = len([p for p in Proxy.query.filter(
|
||||
Proxy.provider == 'azure_cdn',
|
||||
Proxy.destroyed == None
|
||||
).all() if p.origin.group_id == group.id])
|
||||
for origin in group.origins:
|
||||
if active_proxies == 25:
|
||||
break
|
||||
active_proxies += 1
|
||||
azure_cdn_proxies = [
|
||||
x for x in origin.proxies
|
||||
if x.provider == "azure_cdn" and x.deprecated is None and x.destroyed is None
|
||||
]
|
||||
if not azure_cdn_proxies:
|
||||
proxy = Proxy()
|
||||
proxy.origin_id = origin.id
|
||||
proxy.provider = "azure_cdn"
|
||||
proxy.slug = tldextract.extract(origin.domain_name).domain[:5] + ''.join(
|
||||
random.choices(string.ascii_lowercase, k=random.randint(10, 15)))
|
||||
proxy.url = f"https://{proxy.slug}.azureedge.net"
|
||||
proxy.added = datetime.datetime.utcnow()
|
||||
proxy.updated = datetime.datetime.utcnow()
|
||||
db.session.add(proxy)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def set_urls():
|
||||
proxies = Proxy.query.filter(
|
||||
Proxy.provider == 'azure_cdn',
|
||||
Proxy.destroyed == None
|
||||
).all()
|
||||
for proxy in proxies:
|
||||
proxy.url = f"https://{proxy.slug}.azureedge.net"
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def import_monitor_alerts():
|
||||
credential = ClientSecretCredential(
|
||||
tenant_id=app.config['AZURE_TENANT_ID'],
|
||||
client_id=app.config['AZURE_CLIENT_ID'],
|
||||
client_secret=app.config['AZURE_CLIENT_SECRET'])
|
||||
client = AlertsManagementClient(
|
||||
credential,
|
||||
app.config['AZURE_SUBSCRIPTION_ID']
|
||||
)
|
||||
firing = [x.name[len("bandwidth-out-high-bc-"):]
|
||||
for x in client.alerts.get_all()
|
||||
if x.name.startswith("bandwidth-out-high-bc-") and x.properties.essentials.monitor_condition == "Fired"]
|
||||
for proxy in Proxy.query.filter(
|
||||
Proxy.provider == "azure_cdn",
|
||||
Proxy.destroyed == None
|
||||
):
|
||||
alarm = get_proxy_alarm(proxy.id, "bandwidth-out-high")
|
||||
if proxy.origin.group.group_name.lower() not in firing:
|
||||
alarm.update_state(AlarmState.OK, "Azure monitor alert not firing")
|
||||
else:
|
||||
alarm.update_state(AlarmState.CRITICAL, "Azure monitor alert firing")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
auto = ProxyAzureCdnAutomation()
|
||||
auto.create_missing_proxies()
|
||||
auto.destroy_expired_proxies()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply(refresh=False, parallelism=1) # Rate limits are problem
|
||||
set_urls()
|
||||
import_monitor_alerts()
|
156
app/terraform/proxy/cloudfront.py
Normal file
156
app/terraform/proxy/cloudfront.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
import datetime
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import boto3
|
||||
|
||||
from app import app
|
||||
from app.alarms import get_proxy_alarm
|
||||
from app.extensions import db
|
||||
from app.models import Proxy, Alarm, AlarmState
|
||||
from app.terraform.proxy import ProxyAutomation
|
||||
|
||||
|
||||
class ProxyCloudfrontAutomation(ProxyAutomation):
|
||||
short_name = "proxy_cloudfront"
|
||||
provider = "cloudfront"
|
||||
|
||||
template_parameters = [
|
||||
"aws_access_key",
|
||||
"aws_secret_key"
|
||||
]
|
||||
|
||||
template = """
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
version = "~> 4.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
access_key = "{{ aws_access_key }}"
|
||||
secret_key = "{{ aws_secret_key }}"
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
{% for group in groups %}
|
||||
module "label_{{ group.id }}" {
|
||||
source = "cloudposse/label/null"
|
||||
version = "0.25.0"
|
||||
namespace = "{{ global_namespace }}"
|
||||
tenant = "{{ group.group_name }}"
|
||||
label_order = ["namespace", "tenant", "name", "attributes"]
|
||||
}
|
||||
|
||||
module "log_bucket_{{ group.id }}" {
|
||||
source = "cloudposse/s3-log-storage/aws"
|
||||
version = "0.28.0"
|
||||
context = module.label_{{ group.id }}.context
|
||||
name = "logs"
|
||||
attributes = ["cloudfront"]
|
||||
acl = "log-delivery-write"
|
||||
standard_transition_days = 30
|
||||
glacier_transition_days = 60
|
||||
expiration_days = 90
|
||||
}
|
||||
|
||||
resource "aws_sns_topic" "alarms_{{ group.id }}" {
|
||||
name = "${module.label_{{ group.id }}.id}-cloudfront-alarms"
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
{% for proxy in proxies %}
|
||||
module "cloudfront_{{ proxy.id }}" {
|
||||
source = "sr2c/bc-proxy/aws"
|
||||
version = "0.0.5"
|
||||
origin_domain = "{{ proxy.origin.domain_name }}"
|
||||
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
|
||||
context = module.label_{{ proxy.origin.group.id }}.context
|
||||
name = "proxy"
|
||||
attributes = ["{{ proxy.origin.domain_name }}"]
|
||||
}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
def import_cloudfront_values():
|
||||
terraform = subprocess.run(
|
||||
['terraform', 'show', '-json'],
|
||||
cwd=os.path.join(
|
||||
app.config['TERRAFORM_DIRECTORY'],
|
||||
"proxy_cloudfront"),
|
||||
stdout=subprocess.PIPE)
|
||||
state = json.loads(terraform.stdout)
|
||||
|
||||
for mod in state['values']['root_module']['child_modules']:
|
||||
if mod['address'].startswith('module.cloudfront_'):
|
||||
for res in mod['resources']:
|
||||
if res['address'].endswith('aws_cloudfront_distribution.this'):
|
||||
proxy = Proxy.query.filter(Proxy.id == mod['address'][len('module.cloudfront_'):]).first()
|
||||
proxy.url = "https://" + res['values']['domain_name']
|
||||
proxy.slug = res['values']['id']
|
||||
proxy.terraform_updated = datetime.datetime.utcnow()
|
||||
db.session.commit()
|
||||
break
|
||||
|
||||
|
||||
def import_cloudwatch_alarms():
|
||||
cloudwatch = boto3.client('cloudwatch',
|
||||
aws_access_key_id=app.config['AWS_ACCESS_KEY'],
|
||||
aws_secret_access_key=app.config['AWS_SECRET_KEY'],
|
||||
region_name='us-east-1')
|
||||
dist_paginator = cloudwatch.get_paginator('describe_alarms')
|
||||
page_iterator = dist_paginator.paginate(AlarmNamePrefix="bandwidth-out-high-")
|
||||
for page in page_iterator:
|
||||
for cw_alarm in page['MetricAlarms']:
|
||||
dist_id = cw_alarm["AlarmName"][len("bandwidth-out-high-"):]
|
||||
proxy = Proxy.query.filter(Proxy.slug == dist_id).first()
|
||||
if proxy is None:
|
||||
print("Skipping unknown proxy " + dist_id)
|
||||
continue
|
||||
alarm = get_proxy_alarm(proxy.id, "bandwidth-out-high")
|
||||
if cw_alarm['StateValue'] == "OK":
|
||||
alarm.update_state(AlarmState.OK, "CloudWatch alarm OK")
|
||||
elif cw_alarm['StateValue'] == "ALARM":
|
||||
alarm.update_state(AlarmState.CRITICAL, "CloudWatch alarm ALARM")
|
||||
else:
|
||||
alarm.update_state(AlarmState.UNKNOWN, f"CloudWatch alarm {cw_alarm['StateValue']}")
|
||||
alarm = Alarm.query.filter(
|
||||
Alarm.alarm_type == "cloudfront-quota"
|
||||
).first()
|
||||
if alarm is None:
|
||||
alarm = Alarm()
|
||||
alarm.target = "service/cloudfront"
|
||||
alarm.alarm_type = "cloudfront-quota"
|
||||
alarm.state_changed = datetime.datetime.utcnow()
|
||||
db.session.add(alarm)
|
||||
alarm.last_updated = datetime.datetime.utcnow()
|
||||
deployed_count = len(Proxy.query.filter(
|
||||
Proxy.destroyed == None).all())
|
||||
old_state = alarm.alarm_state
|
||||
if deployed_count > 370:
|
||||
alarm.alarm_state = AlarmState.CRITICAL
|
||||
elif deployed_count > 320:
|
||||
alarm.alarm_state = AlarmState.WARNING
|
||||
else:
|
||||
alarm.alarm_state = AlarmState.OK
|
||||
if alarm.alarm_state != old_state:
|
||||
alarm.state_changed = datetime.datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
auto = ProxyCloudfrontAutomation()
|
||||
auto.destroy_expired_proxies()
|
||||
auto.create_missing_proxies()
|
||||
auto.generate_terraform()
|
||||
auto.terraform_init()
|
||||
auto.terraform_apply()
|
||||
import_cloudfront_values()
|
||||
import_cloudwatch_alarms()
|
60
app/terraform/proxy_check.py
Normal file
60
app/terraform/proxy_check.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
import requests
|
||||
|
||||
from app import app
|
||||
from app.extensions import db
|
||||
from app.models import AlarmState, Alarm, Proxy
|
||||
|
||||
|
||||
def set_http_alarm(proxy_id: int, state: AlarmState, text: str):
|
||||
alarm = Alarm.query.filter(
|
||||
Alarm.proxy_id == proxy_id,
|
||||
Alarm.alarm_type == "http-status"
|
||||
).first()
|
||||
if alarm is None:
|
||||
alarm = Alarm()
|
||||
alarm.proxy_id = proxy_id
|
||||
alarm.alarm_type = "http-status"
|
||||
db.session.add(alarm)
|
||||
alarm.update_state(state, text)
|
||||
|
||||
|
||||
def check_http():
|
||||
proxies = Proxy.query.filter(
|
||||
Proxy.destroyed == None
|
||||
)
|
||||
for proxy in proxies:
|
||||
try:
|
||||
if proxy.url is None:
|
||||
continue
|
||||
r = requests.get(proxy.url,
|
||||
allow_redirects=False,
|
||||
timeout=5)
|
||||
r.raise_for_status()
|
||||
if r.is_redirect:
|
||||
set_http_alarm(
|
||||
proxy.id,
|
||||
AlarmState.CRITICAL,
|
||||
f"{r.status_code} {r.reason}"
|
||||
)
|
||||
else:
|
||||
set_http_alarm(
|
||||
proxy.id,
|
||||
AlarmState.OK,
|
||||
f"{r.status_code} {r.reason}"
|
||||
)
|
||||
except (requests.ConnectionError, requests.Timeout):
|
||||
set_http_alarm(
|
||||
proxy.id,
|
||||
AlarmState.CRITICAL,
|
||||
f"Connection failure")
|
||||
except requests.HTTPError:
|
||||
set_http_alarm(
|
||||
proxy.id,
|
||||
AlarmState.CRITICAL,
|
||||
f"{r.status_code} {r.reason}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
check_http()
|
33
config.yaml.example
Normal file
33
config.yaml.example
Normal file
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
# Supports any backend supported by SQLAlchemy, but you may need additional
|
||||
# packages installed if you're not using SQLite.
|
||||
SQLALCHEMY_DATABASE_URI: sqlite:///example.db
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS: true
|
||||
|
||||
# You can just put whatever here, but you should change it!
|
||||
SECRET_KEY: iechaj0mun6beih3rooga0mei7eo0iwoal1eeweN
|
||||
|
||||
# This directory must exist and be writable by the user running the portal.
|
||||
TERRAFORM_DIRECTORY: /home/bc/terraform
|
||||
|
||||
# AWS (CloudFront)
|
||||
AWS_ACCESS_KEY: accesskeygoeshere
|
||||
AWS_SECRET_KEY: accesssecretgoeshere
|
||||
|
||||
# Azure
|
||||
AZURE_RESOURCE_GROUP_NAME: namegoeshere
|
||||
AZURE_STORAGE_ACCOUNT_NAME: namegoeshere
|
||||
AZURE_LOCATION: westcentralus
|
||||
AZURE_SUBSCRIPTION_ID: subscriptionuuid
|
||||
AZURE_TENANT_ID: tenantuuid
|
||||
AZURE_CLIENT_ID: clientuuid
|
||||
AZURE_CLIENT_SECRET: clientsecretgoeshere
|
||||
|
||||
# GitHub
|
||||
GITHUB_ORGANIZATION: exampleorg
|
||||
GITHUB_REPOSITORY: example-repo
|
||||
GITHUB_API_KEY: keygoeshere
|
||||
GITHUB_FILE_V2: mirrorSites.json
|
||||
|
||||
# Hetzner Cloud
|
||||
HCLOUD_TOKEN: tokengoeshere
|
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
3
docs/admin/index.rst
Normal file
3
docs/admin/index.rst
Normal file
|
@ -0,0 +1,3 @@
|
|||
Application Overview
|
||||
====================
|
||||
|
41
docs/conf.py
Normal file
41
docs/conf.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'Bypass Censorship'
|
||||
copyright = '2022, Bypass Censorship'
|
||||
author = 'Bypass Censorship'
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc'
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'press'
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
35
docs/index.rst
Normal file
35
docs/index.rst
Normal file
|
@ -0,0 +1,35 @@
|
|||
.. Bypass Censorship documentation master file, created by
|
||||
sphinx-quickstart on Fri Apr 8 12:02:43 2022.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Documentation Home
|
||||
==================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: User Guide:
|
||||
|
||||
user/index.rst
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Admin Guide:
|
||||
|
||||
admin/index.rst
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Technical Documentation:
|
||||
|
||||
tech/index.rst
|
||||
tech/conf.rst
|
||||
tech/resource.rst
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
|
@ -0,0 +1,35 @@
|
|||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=.
|
||||
set BUILDDIR=_build
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
8
docs/tech/conf.rst
Normal file
8
docs/tech/conf.rst
Normal file
|
@ -0,0 +1,8 @@
|
|||
Configuration Objects
|
||||
=====================
|
||||
|
||||
.. autoclass:: app.models.AbstractConfiguration
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
4
docs/tech/index.rst
Normal file
4
docs/tech/index.rst
Normal file
|
@ -0,0 +1,4 @@
|
|||
Technical Overview
|
||||
==================
|
||||
|
||||
|
8
docs/tech/resource.rst
Normal file
8
docs/tech/resource.rst
Normal file
|
@ -0,0 +1,8 @@
|
|||
Resource Objects
|
||||
================
|
||||
|
||||
.. autoclass:: app.models.AbstractResource
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
3
docs/user/index.rst
Normal file
3
docs/user/index.rst
Normal file
|
@ -0,0 +1,3 @@
|
|||
Introduction
|
||||
============
|
||||
|
1
migrations/README
Normal file
1
migrations/README
Normal file
|
@ -0,0 +1 @@
|
|||
Single-database configuration for Flask.
|
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
|
@ -0,0 +1,50 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
91
migrations/env.py
Normal file
91
migrations/env.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
from __future__ import with_statement
|
||||
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option(
|
||||
'sqlalchemy.url',
|
||||
str(current_app.extensions['migrate'].db.get_engine().url).replace(
|
||||
'%', '%%'))
|
||||
target_metadata = current_app.extensions['migrate'].db.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=target_metadata, literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
connectable = current_app.extensions['migrate'].db.get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
process_revision_directives=process_revision_directives,
|
||||
**current_app.extensions['migrate'].configure_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
124
migrations/versions/07c4fb2af22c_initial_schema.py
Normal file
124
migrations/versions/07c4fb2af22c_initial_schema.py
Normal file
|
@ -0,0 +1,124 @@
|
|||
"""initial schema
|
||||
|
||||
Revision ID: 07c4fb2af22c
|
||||
Revises:
|
||||
Create Date: 2022-03-31 12:36:02.922753
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '07c4fb2af22c'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('group',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('group_name', sa.String(length=80), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=False),
|
||||
sa.Column('eotk', sa.Boolean(), nullable=True),
|
||||
sa.Column('added', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_group')),
|
||||
sa.UniqueConstraint('group_name', name=op.f('uq_group_group_name'))
|
||||
)
|
||||
op.create_table('bridge_conf',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('group_id', sa.Integer(), nullable=False),
|
||||
sa.Column('provider', sa.String(length=20), nullable=False),
|
||||
sa.Column('method', sa.String(length=20), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('number', sa.Integer(), nullable=True),
|
||||
sa.Column('added', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated', sa.DateTime(), nullable=False),
|
||||
sa.Column('destroyed', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['group.id'], name=op.f('fk_bridge_conf_group_id_group')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_bridge_conf'))
|
||||
)
|
||||
op.create_table('origin',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('group_id', sa.Integer(), nullable=False),
|
||||
sa.Column('domain_name', sa.String(length=255), 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.ForeignKeyConstraint(['group_id'], ['group.id'], name=op.f('fk_origin_group_id_group')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_origin')),
|
||||
sa.UniqueConstraint('domain_name', name=op.f('uq_origin_domain_name'))
|
||||
)
|
||||
op.create_table('bridge',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('conf_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('destroyed', sa.DateTime(), nullable=True),
|
||||
sa.Column('terraform_updated', sa.DateTime(), 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'))
|
||||
)
|
||||
op.create_table('mirror',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('origin_id', sa.Integer(), nullable=False),
|
||||
sa.Column('url', sa.String(length=255), 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('destroyed', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['origin_id'], ['origin.id'], name=op.f('fk_mirror_origin_id_origin')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mirror')),
|
||||
sa.UniqueConstraint('url', name=op.f('uq_mirror_url'))
|
||||
)
|
||||
op.create_table('proxy',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('origin_id', sa.Integer(), nullable=False),
|
||||
sa.Column('provider', sa.String(length=20), nullable=False),
|
||||
sa.Column('slug', sa.String(length=20), nullable=True),
|
||||
sa.Column('added', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated', sa.DateTime(), nullable=False),
|
||||
sa.Column('deprecated', sa.DateTime(), nullable=True),
|
||||
sa.Column('destroyed', sa.DateTime(), nullable=True),
|
||||
sa.Column('terraform_updated', sa.DateTime(), nullable=True),
|
||||
sa.Column('url', sa.String(length=255), nullable=True),
|
||||
sa.ForeignKeyConstraint(['origin_id'], ['origin.id'], name=op.f('fk_proxy_origin_id_origin')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_proxy'))
|
||||
)
|
||||
op.create_table('alarm',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('target', sa.String(length=60), nullable=False),
|
||||
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||
sa.Column('origin_id', sa.Integer(), nullable=True),
|
||||
sa.Column('proxy_id', sa.Integer(), nullable=True),
|
||||
sa.Column('bridge_id', sa.Integer(), nullable=True),
|
||||
sa.Column('alarm_type', sa.String(length=255), nullable=False),
|
||||
sa.Column('alarm_state', sa.Enum('UNKNOWN', 'OK', 'WARNING', 'CRITICAL', name='alarmstate'), nullable=False),
|
||||
sa.Column('state_changed', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_updated', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['bridge_id'], ['bridge.id'], name=op.f('fk_alarm_bridge_id_bridge')),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['group.id'], name=op.f('fk_alarm_group_id_group')),
|
||||
sa.ForeignKeyConstraint(['origin_id'], ['origin.id'], name=op.f('fk_alarm_origin_id_origin')),
|
||||
sa.ForeignKeyConstraint(['proxy_id'], ['proxy.id'], name=op.f('fk_alarm_proxy_id_proxy')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_alarm'))
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('alarm')
|
||||
op.drop_table('proxy')
|
||||
op.drop_table('mirror')
|
||||
op.drop_table('bridge')
|
||||
op.drop_table('origin')
|
||||
op.drop_table('bridge_conf')
|
||||
op.drop_table('group')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,38 @@
|
|||
"""alarms text and destroy origins
|
||||
|
||||
Revision ID: 59c9a5185e88
|
||||
Revises: 5c69fe874e4d
|
||||
Create Date: 2022-04-07 16:30:27.888327
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '59c9a5185e88'
|
||||
down_revision = '5c69fe874e4d'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('alarm', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('text', sa.String(length=255), nullable=True))
|
||||
|
||||
with op.batch_alter_table('origin', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('destroyed', sa.DateTime(), nullable=True))
|
||||
|
||||
# ### 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('destroyed')
|
||||
|
||||
with op.batch_alter_table('alarm', schema=None) as batch_op:
|
||||
batch_op.drop_column('text')
|
||||
|
||||
# ### end Alembic commands ###
|
32
migrations/versions/5c69fe874e4d_add_bridge_nicknames.py
Normal file
32
migrations/versions/5c69fe874e4d_add_bridge_nicknames.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
"""add bridge nicknames
|
||||
|
||||
Revision ID: 5c69fe874e4d
|
||||
Revises: e1332e4cb910
|
||||
Create Date: 2022-04-05 15:48:36.552558
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5c69fe874e4d'
|
||||
down_revision = 'e1332e4cb910'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('bridge', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('nickname', sa.String(length=255), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('bridge', schema=None) as batch_op:
|
||||
batch_op.drop_column('nickname')
|
||||
|
||||
# ### end Alembic commands ###
|
41
migrations/versions/e1332e4cb910_add_mirror_lists.py
Normal file
41
migrations/versions/e1332e4cb910_add_mirror_lists.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
"""add mirror lists
|
||||
|
||||
Revision ID: e1332e4cb910
|
||||
Revises: 07c4fb2af22c
|
||||
Create Date: 2022-03-31 13:33:49.067575
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e1332e4cb910'
|
||||
down_revision = '07c4fb2af22c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('mirror_list',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('provider', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=False),
|
||||
sa.Column('format', sa.String(length=20), nullable=False),
|
||||
sa.Column('container', sa.String(length=255), nullable=False),
|
||||
sa.Column('branch', sa.String(length=255), nullable=False),
|
||||
sa.Column('filename', sa.String(length=255), 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('destroyed', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mirror_list'))
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('mirror_list')
|
||||
# ### end Alembic commands ###
|
16
requirements.txt
Normal file
16
requirements.txt
Normal file
|
@ -0,0 +1,16 @@
|
|||
flask~=2.0.2
|
||||
wtforms~=3.0.1
|
||||
boto3~=1.21.15
|
||||
alembic~=1.7.6
|
||||
sqlalchemy~=1.4.32
|
||||
pyyaml~=6.0
|
||||
jinja2~=3.0.2
|
||||
tldextract~=3.2.0
|
||||
requests~=2.27.1
|
||||
azure-identity
|
||||
azure-mgmt-alertsmanagement
|
||||
flask-migrate
|
||||
flask-sqlalchemy
|
||||
bootstrap-flask
|
||||
flask-wtf
|
||||
PyGithub
|
Loading…
Add table
Add a link
Reference in a new issue