mirror of
https://gitlab.com/SIGBUS/nyaa.git
synced 2024-12-22 10:59:59 +00:00
Implement range bans (#478)
* Implement range bans People connecting from banned IP ranges are unable to upload torrents anonymously, and need to manually have their accounts activated. This adds a new table "rangebans", and a command line utility, "rangeban.py", which can be used to add, list and remove rangebans from the command line. As an example: ./rangeban.py ban 192.168.0.0/24 This would rangeban anything in this /24. The temporary_tor column allows automated scripts to clean out and re-add ever-changing sets of ranges to be banned without affecting the other ranges. This has only been tested for IPv4. * Revise Rangebans Add an id column, and change "temporary_tor" to "temp". Also index masked_cidr and mask. * rangebans: fix enabled and the binary op kill me * Add enabling/disabling bans to rangeban.py * rangebans: fail earlier on garbage arguments * rangebans: fix linter errors * rangeban.py: don't shadow builtin keyword 'id' * rangebans: change temporary ban logic, column The 'temp' column is now a nullable time column. If the field is null, the ban is understood to be permanent. If there is a time in there, it's understood to be the creation time of the ban. This allows scripts to e.g. delete all temporary bans older than a certain amount of time. Also, rename the '_cidr_string' column to 'cidr_string', because reasons. * rangeban.py: use ip_address to parse CIDR subnet * rangebans: fixes to the mask calculation and query Both were not bugs per-se, but just technically not needed/correct. * De-meme apparently
This commit is contained in:
parent
f04e0fd2ae
commit
a38e5d5b53
40
migrations/versions/f69d7fec88d6_add_rangebans.py
Normal file
40
migrations/versions/f69d7fec88d6_add_rangebans.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
"""add rangebans
|
||||||
|
|
||||||
|
Revision ID: f69d7fec88d6
|
||||||
|
Revises: 6cc823948c5a
|
||||||
|
Create Date: 2018-06-01 14:01:49.596007
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'f69d7fec88d6'
|
||||||
|
down_revision = '6cc823948c5a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('rangebans',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('cidr_string', sa.String(length=18), nullable=False),
|
||||||
|
sa.Column('masked_cidr', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('mask', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('temp', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_rangebans_mask'), 'rangebans', ['mask'], unique=False)
|
||||||
|
op.create_index(op.f('ix_rangebans_masked_cidr'), 'rangebans', ['masked_cidr'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_rangebans_masked_cidr'), table_name='rangebans')
|
||||||
|
op.drop_index(op.f('ix_rangebans_mask'), table_name='rangebans')
|
||||||
|
op.drop_table('rangebans')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -158,6 +158,12 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
|
||||||
upload_form.ratelimit.errors = ["You've gone over the upload ratelimit."]
|
upload_form.ratelimit.errors = ["You've gone over the upload ratelimit."]
|
||||||
raise TorrentExtraValidationException()
|
raise TorrentExtraValidationException()
|
||||||
|
|
||||||
|
if not uploading_user:
|
||||||
|
if models.RangeBan.is_rangebanned(ip_address(flask.request.remote_addr).packed):
|
||||||
|
upload_form.rangebanned.errors = ["Your IP is banned from "
|
||||||
|
"uploading anonymously."]
|
||||||
|
raise TorrentExtraValidationException()
|
||||||
|
|
||||||
# Delete existing torrent which is marked as deleted
|
# Delete existing torrent which is marked as deleted
|
||||||
if torrent_data.db_id is not None:
|
if torrent_data.db_id is not None:
|
||||||
old_torrent = models.Torrent.by_id(torrent_data.db_id)
|
old_torrent = models.Torrent.by_id(torrent_data.db_id)
|
||||||
|
|
|
@ -349,6 +349,7 @@ class UploadForm(FlaskForm):
|
||||||
])
|
])
|
||||||
|
|
||||||
ratelimit = HiddenField()
|
ratelimit = HiddenField()
|
||||||
|
rangebanned = HiddenField()
|
||||||
|
|
||||||
def validate_torrent_file(form, field):
|
def validate_torrent_file(form, field):
|
||||||
# Decode and ensure data is bencoded data
|
# Decode and ensure data is bencoded data
|
||||||
|
|
|
@ -775,6 +775,44 @@ class TrackerApiBase(DeclarativeHelperBase):
|
||||||
self.method = method
|
self.method = method
|
||||||
|
|
||||||
|
|
||||||
|
class RangeBan(db.Model):
|
||||||
|
__tablename__ = 'rangebans'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
_cidr_string = db.Column('cidr_string', db.String(length=18), nullable=False)
|
||||||
|
masked_cidr = db.Column(db.BigInteger, nullable=False,
|
||||||
|
index=True)
|
||||||
|
mask = db.Column(db.BigInteger, nullable=False, index=True)
|
||||||
|
enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
# If this rangeban may be automatically cleared once it becomes
|
||||||
|
# out of date, set this column to the creation time of the ban.
|
||||||
|
# None (or NULL in the db) is understood as the ban being permanent.
|
||||||
|
temp = db.Column(db.DateTime(timezone=False), nullable=True, default=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cidr_string(self):
|
||||||
|
return self._cidr_string
|
||||||
|
|
||||||
|
@cidr_string.setter
|
||||||
|
def cidr_string(self, s):
|
||||||
|
subnet, masked_bits = s.split('/')
|
||||||
|
subnet_b = ip_address(subnet).packed
|
||||||
|
self.mask = (1 << 32) - (1 << (32 - int(masked_bits)))
|
||||||
|
self.masked_cidr = int.from_bytes(subnet_b, 'big') & self.mask
|
||||||
|
self._cidr_string = s
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_rangebanned(cls, ip):
|
||||||
|
if len(ip) > 4:
|
||||||
|
raise NotImplementedError("IPv6 is unsupported.")
|
||||||
|
elif len(ip) < 4:
|
||||||
|
raise ValueError("Not an IP address.")
|
||||||
|
ip_int = int.from_bytes(ip, 'big')
|
||||||
|
q = cls.query.filter(cls.mask.op('&')(ip_int) == cls.masked_cidr,
|
||||||
|
cls.enabled)
|
||||||
|
return q.count() > 0
|
||||||
|
|
||||||
|
|
||||||
# Actually declare our site-specific classes
|
# Actually declare our site-specific classes
|
||||||
|
|
||||||
# Torrent
|
# Torrent
|
||||||
|
|
|
@ -37,6 +37,18 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if upload_form.rangebanned.errors %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{% for error in upload_form.rangebanned.errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
{{ render_upload(upload_form.torrent_file, accept=".torrent") }}
|
{{ render_upload(upload_form.torrent_file, accept=".torrent") }}
|
||||||
|
|
|
@ -89,19 +89,27 @@ def register():
|
||||||
user.last_login_ip = ip_address(flask.request.remote_addr).packed
|
user.last_login_ip = ip_address(flask.request.remote_addr).packed
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
if models.RangeBan.is_rangebanned(user.last_login_ip):
|
||||||
if app.config['USE_EMAIL_VERIFICATION']: # force verification, enable email
|
flask.flash(flask.Markup('Your IP is blocked from creating new accounts. '
|
||||||
send_verification_email(user)
|
'Please <a href="{}">ask a moderator</a> to manually '
|
||||||
return flask.render_template('waiting.html')
|
'activate your account <a href="{}">\'{}\'</a>.'
|
||||||
else: # disable verification, set user as active and auto log in
|
.format(flask.url_for('site.help') + '#irchelp',
|
||||||
user.status = models.UserStatusType.ACTIVE
|
flask.url_for('users.view_user',
|
||||||
db.session.add(user)
|
user_name=user.username),
|
||||||
db.session.commit()
|
user.username)), 'warning')
|
||||||
flask.g.user = user
|
else:
|
||||||
flask.session['user_id'] = user.id
|
if app.config['USE_EMAIL_VERIFICATION']: # force verification, enable email
|
||||||
flask.session.permanent = True
|
send_verification_email(user)
|
||||||
flask.session.modified = True
|
return flask.render_template('waiting.html')
|
||||||
return flask.redirect(redirect_url())
|
else: # disable verification, set user as active and auto log in
|
||||||
|
user.status = models.UserStatusType.ACTIVE
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
flask.g.user = user
|
||||||
|
flask.session['user_id'] = user.id
|
||||||
|
flask.session.permanent = True
|
||||||
|
flask.session.modified = True
|
||||||
|
return flask.redirect(redirect_url())
|
||||||
|
|
||||||
return flask.render_template('register.html', form=form)
|
return flask.render_template('register.html', form=form)
|
||||||
|
|
||||||
|
|
119
rangeban.py
Executable file
119
rangeban.py
Executable file
|
@ -0,0 +1,119 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from ipaddress import ip_address
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from nyaa import create_app, models
|
||||||
|
from nyaa.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
def is_cidr_valid(c):
|
||||||
|
'''Checks whether a CIDR range string is valid.'''
|
||||||
|
try:
|
||||||
|
subnet, mask = c.split('/')
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if int(mask) < 1 or int(mask) > 32:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ip = ip_address(subnet)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_str(b):
|
||||||
|
'''Returns a checkmark or cross depending on the condition.'''
|
||||||
|
return '\u2713' if b else '\u2717'
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def rangeban():
|
||||||
|
global app
|
||||||
|
app = create_app('config')
|
||||||
|
|
||||||
|
|
||||||
|
@rangeban.command()
|
||||||
|
@click.option('--temp/--no-temp', help='Mark this entry as one that may be '
|
||||||
|
'cleaned out occasionally.', default=False)
|
||||||
|
@click.argument('cidrrange')
|
||||||
|
def ban(temp, cidrrange):
|
||||||
|
if not is_cidr_valid(cidrrange):
|
||||||
|
click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.'
|
||||||
|
.format(cidrrange), err=True, fg='red')
|
||||||
|
sys.exit(1)
|
||||||
|
with app.app_context():
|
||||||
|
ban = models.RangeBan(cidr_string=cidrrange, temp=datetime.utcnow() if temp else None)
|
||||||
|
db.session.add(ban)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo('Added {} for {}.'.format('temp ban' if temp else 'ban',
|
||||||
|
cidrrange))
|
||||||
|
|
||||||
|
|
||||||
|
@rangeban.command()
|
||||||
|
@click.argument('cidrrange')
|
||||||
|
def unban(cidrrange):
|
||||||
|
if not is_cidr_valid(cidrrange):
|
||||||
|
click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.'
|
||||||
|
.format(cidrrange), err=True, fg='red')
|
||||||
|
sys.exit(1)
|
||||||
|
with app.app_context():
|
||||||
|
# Dunno why this wants _cidr_string and not cidr_string, probably
|
||||||
|
# due to this all being a janky piece of shit.
|
||||||
|
bans = models.RangeBan.query.filter(
|
||||||
|
models.RangeBan._cidr_string == cidrrange).all()
|
||||||
|
if len(bans) == 0:
|
||||||
|
click.echo('Ban not found.')
|
||||||
|
for b in bans:
|
||||||
|
click.echo('Unbanned {}'.format(b.cidr_string))
|
||||||
|
db.session.delete(b)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@rangeban.command()
|
||||||
|
def list():
|
||||||
|
with app.app_context():
|
||||||
|
bans = models.RangeBan.query.all()
|
||||||
|
if len(bans) == 0:
|
||||||
|
click.echo('No bans.')
|
||||||
|
else:
|
||||||
|
click.secho('ID CIDR Range Enabled Temp', bold=True)
|
||||||
|
for b in bans:
|
||||||
|
click.echo('{0: <6} {1: <18} {2: <7} {3: <4}'
|
||||||
|
.format(b.id, b.cidr_string,
|
||||||
|
check_str(b.enabled),
|
||||||
|
check_str(b.temp is not None)))
|
||||||
|
|
||||||
|
@rangeban.command()
|
||||||
|
@click.argument('banid', type=int)
|
||||||
|
@click.argument('status')
|
||||||
|
def enabled(banid, status):
|
||||||
|
yeses = ['true', '1', 'yes', '\u2713']
|
||||||
|
noses = ['false', '0', 'no', '\u2717']
|
||||||
|
if status.lower() in yeses:
|
||||||
|
set_to = True
|
||||||
|
elif status.lower() in noses:
|
||||||
|
set_to = False
|
||||||
|
else:
|
||||||
|
click.secho('Please choose one of {} or {}.'
|
||||||
|
.format(yeses, noses), err=True, fg='red')
|
||||||
|
sys.exit(1)
|
||||||
|
with app.app_context():
|
||||||
|
ban = models.RangeBan.query.get(banid)
|
||||||
|
if not ban:
|
||||||
|
click.secho('No ban with id {} found.'
|
||||||
|
.format(banid), err=True, fg='red')
|
||||||
|
sys.exit(1)
|
||||||
|
ban.enabled = set_to
|
||||||
|
db.session.add(ban)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo('{} ban {} on {}.'.format('Enabled' if set_to else 'Disabled',
|
||||||
|
banid, ban._cidr_string))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
rangeban()
|
Loading…
Reference in a new issue