mirror of
				https://github.com/sb745/NyaaV3.git
				synced 2025-10-31 16:05:46 +02: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
					
				
					 7 changed files with 237 additions and 13 deletions
				
			
		
							
								
								
									
										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."] | ||||
|             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 | ||||
|     if torrent_data.db_id is not None: | ||||
|         old_torrent = models.Torrent.by_id(torrent_data.db_id) | ||||
|  |  | |||
|  | @ -349,6 +349,7 @@ class UploadForm(FlaskForm): | |||
|     ]) | ||||
| 
 | ||||
|     ratelimit = HiddenField() | ||||
|     rangebanned = HiddenField() | ||||
| 
 | ||||
|     def validate_torrent_file(form, field): | ||||
|         # Decode and ensure data is bencoded data | ||||
|  |  | |||
|  | @ -775,6 +775,44 @@ class TrackerApiBase(DeclarativeHelperBase): | |||
|         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 | ||||
| 
 | ||||
| # Torrent | ||||
|  |  | |||
|  | @ -37,6 +37,18 @@ | |||
| 	</div> | ||||
| 	{% 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="col-md-12"> | ||||
| 		{{ 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 | ||||
|         db.session.add(user) | ||||
|         db.session.commit() | ||||
| 
 | ||||
|         if app.config['USE_EMAIL_VERIFICATION']:  # force verification, enable email | ||||
|             send_verification_email(user) | ||||
|             return flask.render_template('waiting.html') | ||||
|         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()) | ||||
|         if models.RangeBan.is_rangebanned(user.last_login_ip): | ||||
|             flask.flash(flask.Markup('Your IP is blocked from creating new accounts. ' | ||||
|                                      'Please <a href="{}">ask a moderator</a> to manually ' | ||||
|                                      'activate your account <a href="{}">\'{}\'</a>.' | ||||
|                                      .format(flask.url_for('site.help') + '#irchelp', | ||||
|                                              flask.url_for('users.view_user', | ||||
|                                                            user_name=user.username), | ||||
|                                              user.username)), 'warning') | ||||
|         else: | ||||
|             if app.config['USE_EMAIL_VERIFICATION']:  # force verification, enable email | ||||
|                 send_verification_email(user) | ||||
|                 return flask.render_template('waiting.html') | ||||
|             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) | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										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…
	
	Add table
		
		Reference in a new issue
	
	 Nicolas F
						Nicolas F