Merge branch 'eigenmagic:main' into main
This commit is contained in:
commit
7989627c09
71
README.md
71
README.md
|
@ -81,20 +81,28 @@ token.
|
||||||
|
|
||||||
The application needs the `admin:read:domain_blocks` OAuth scope, but
|
The application needs the `admin:read:domain_blocks` OAuth scope, but
|
||||||
unfortunately this scope isn't available in the current application screen
|
unfortunately this scope isn't available in the current application screen
|
||||||
(v4.0.2 of Mastodon at time of writing). There is a way to do it with scopes,
|
(v4.0.2 of Mastodon at time of writing, but this has been fixed in the main
|
||||||
but it's really dangerous, so I'm not going to tell you what it is here.
|
branch).
|
||||||
|
|
||||||
A better way is to ask the instance admin to connect to the PostgreSQL database
|
You can allow full `admin:read` access, but be aware that this authorizes
|
||||||
and add the scope there, like this:
|
someone to read all the data in the instance. That's asking a lot of a remote
|
||||||
|
instance admin who just wants to share domain_blocks with you.
|
||||||
|
|
||||||
|
For now, you can ask the instance admin to update the scope in the database
|
||||||
|
directly like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
UPDATE oauth_access_tokens
|
UPDATE oauth_applications as app
|
||||||
SET scopes = 'admin:read:domain_blocks'
|
SET scopes = 'admin:read:domain_blocks'
|
||||||
WHERE token='<your_app_token>';
|
FROM oauth_access_tokens as tok
|
||||||
|
WHERE app.id = tok.application_id
|
||||||
|
AND app.name = '<the_app_name>'
|
||||||
|
;
|
||||||
```
|
```
|
||||||
|
|
||||||
When that's done, FediBlockHole should be able to use its token to read domain
|
When that's done, regenerate the token (so it has the new scopes) in the
|
||||||
blocks via the API.
|
application screen in the instance GUI. FediBlockHole should then able to use
|
||||||
|
the app token to read domain blocks via the API, but nothing else.
|
||||||
|
|
||||||
Alternately, you could ask the remote instance admin to set up FediBlockHole and
|
Alternately, you could ask the remote instance admin to set up FediBlockHole and
|
||||||
use it to dump out a CSV blocklist from their instance and then put it somewhere
|
use it to dump out a CSV blocklist from their instance and then put it somewhere
|
||||||
|
@ -104,12 +112,17 @@ as explained below.
|
||||||
### Writing instance blocklists
|
### Writing instance blocklists
|
||||||
|
|
||||||
To write domain blocks into an instance requires both the `admin:read` and
|
To write domain blocks into an instance requires both the `admin:read` and
|
||||||
`admin:write:domain_blocks` OAuth scopes. The `read` scope is used to read the
|
`admin:write:domain_blocks` OAuth scopes.
|
||||||
current list of domain blocks so we update ones that already exist, rather than
|
|
||||||
trying to add all new ones and clutter up the instance. It's also used to check
|
The tool needs `admin:read:domain_blocks` scope to read the current list of
|
||||||
if the instance has any accounts that follow accounts on a domain that is about
|
domain blocks so we update ones that already exist, rather than trying to add
|
||||||
to get `suspend`ed and automatically drop the block severity to `silence` level
|
all new ones and clutter up the instance.
|
||||||
so people have time to migrate accounts before a full defederation takes effect.
|
|
||||||
|
`admin:read` access is needed to check if the instance has any accounts that
|
||||||
|
follow accounts on a domain that is about to get `suspend`ed and automatically
|
||||||
|
drop the block severity to `silence` level so people have time to migrate
|
||||||
|
accounts before a full defederation takes effect. Unfortunately, the statistics
|
||||||
|
measure used to learn this information requires `admin:read` scope.
|
||||||
|
|
||||||
You can add `admin:read` scope in the application admin screen. Please be aware
|
You can add `admin:read` scope in the application admin screen. Please be aware
|
||||||
that this grants full read access to all information in the instance to the
|
that this grants full read access to all information in the instance to the
|
||||||
|
@ -122,12 +135,15 @@ chmod o-r <configfile>
|
||||||
|
|
||||||
You can also grant full `admin:write` scope to the application, but if you'd
|
You can also grant full `admin:write` scope to the application, but if you'd
|
||||||
prefer to keep things more tightly secured you'll need to use SQL to set the
|
prefer to keep things more tightly secured you'll need to use SQL to set the
|
||||||
scopes in the database:
|
scopes in the database and then regenerate the token:
|
||||||
|
|
||||||
```
|
```
|
||||||
UPDATE oauth_access_tokens
|
UPDATE oauth_applications as app
|
||||||
SET scopes = 'admin:read admin:write:domain_blocks'
|
SET scopes = 'admin:read admin:write:domain_blocks'
|
||||||
WHERE token='<your_app_token>';
|
FROM oauth_access_tokens as tok
|
||||||
|
WHERE app.id = tok.application_id
|
||||||
|
AND app.name = '<the_app_name>'
|
||||||
|
;
|
||||||
```
|
```
|
||||||
|
|
||||||
When that's done, FediBlockHole should be able to use its token to authorise
|
When that's done, FediBlockHole should be able to use its token to authorise
|
||||||
|
@ -159,11 +175,12 @@ Or you can use the default location of `/etc/default/fediblockhole.conf.toml`.
|
||||||
|
|
||||||
As the filename suggests, FediBlockHole uses TOML syntax.
|
As the filename suggests, FediBlockHole uses TOML syntax.
|
||||||
|
|
||||||
There are 3 key sections:
|
There are 4 key sections:
|
||||||
|
|
||||||
1. `blocklist_urls_sources`: A list of URLs to read blocklists from
|
1. `blocklist_urls_sources`: A list of URLs to read blocklists from
|
||||||
1. `blocklist_instance_sources`: A list of Mastodon instances to read blocklists from via API
|
1. `blocklist_instance_sources`: A list of Mastodon instances to read blocklists from via API
|
||||||
1. `blocklist_instance_destinations`: A list of Mastodon instances to write blocklists to via API
|
1. `blocklist_instance_destinations`: A list of Mastodon instances to write blocklists to via API
|
||||||
|
1. `allowlist_url_sources`: A list of URLs to read allowlists from
|
||||||
|
|
||||||
More detail on configuring the tool is provided below.
|
More detail on configuring the tool is provided below.
|
||||||
|
|
||||||
|
@ -286,6 +303,24 @@ mergeplan.
|
||||||
Once the follow count drops to 0 on your instance, the tool will automatically
|
Once the follow count drops to 0 on your instance, the tool will automatically
|
||||||
use the highest severity it finds again (if you're using the `max` mergeplan).
|
use the highest severity it finds again (if you're using the `max` mergeplan).
|
||||||
|
|
||||||
|
### Allowlists
|
||||||
|
|
||||||
|
Sometimes you might want to completely ignore the blocklist definitions for
|
||||||
|
certain domains. That's what allowlists are for.
|
||||||
|
|
||||||
|
Allowlists remove any domain in the list from the merged list of blocks before
|
||||||
|
the merged list is saved out to a file or pushed to any instance.
|
||||||
|
|
||||||
|
Allowlists can be in any format supported by `blocklist_urls_sources` but ignore
|
||||||
|
all fields that aren't `domain`.
|
||||||
|
|
||||||
|
You can also allow domains on the commandline by using the `-A` or `--allow`
|
||||||
|
flag and providing the domain name to allow. You can use the flag multiple
|
||||||
|
times to allow multiple domains.
|
||||||
|
|
||||||
|
It is probably wise to include your own instance domain in an allowlist so you
|
||||||
|
don't accidentally defederate from yourself.
|
||||||
|
|
||||||
## More advanced configuration
|
## More advanced configuration
|
||||||
|
|
||||||
For a list of possible configuration options, check the `--help` and read the
|
For a list of possible configuration options, check the `--help` and read the
|
||||||
|
|
|
@ -16,7 +16,7 @@ blocklist_instance_sources = [
|
||||||
# max_severity tells the parser to override any severities that are higher than this value
|
# max_severity tells the parser to override any severities that are higher than this value
|
||||||
# import_fields tells the parser to only import that set of fields from a specific source
|
# import_fields tells the parser to only import that set of fields from a specific source
|
||||||
blocklist_url_sources = [
|
blocklist_url_sources = [
|
||||||
# { url = 'file:///home/daedalus/src/fediblockhole/samples/demo-blocklist-01.csv', format = 'csv' },
|
# { url = 'file:///path/to/fediblockhole/samples/demo-blocklist-01.csv', format = 'csv' },
|
||||||
{ url = 'https://raw.githubusercontent.com/eigenmagic/fediblockhole/main/samples/demo-blocklist-01.csv', format = 'csv' },
|
{ url = 'https://raw.githubusercontent.com/eigenmagic/fediblockhole/main/samples/demo-blocklist-01.csv', format = 'csv' },
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "fediblockhole"
|
name = "fediblockhole"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
description = "Federated blocklist management for Mastodon"
|
description = "Federated blocklist management for Mastodon"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
|
|
|
@ -35,10 +35,13 @@ API_CALL_DELAY = 5 * 60 / 300 # 300 calls per 5 minutes
|
||||||
# We always import the domain and the severity
|
# We always import the domain and the severity
|
||||||
IMPORT_FIELDS = ['domain', 'severity']
|
IMPORT_FIELDS = ['domain', 'severity']
|
||||||
|
|
||||||
|
# Allowlists always import these fields
|
||||||
|
ALLOWLIST_IMPORT_FIELDS = ['domain', 'severity', 'public_comment', 'private_comment', 'reject_media', 'reject_reports', 'obfuscate']
|
||||||
|
|
||||||
# We always export the domain and the severity
|
# We always export the domain and the severity
|
||||||
EXPORT_FIELDS = ['domain', 'severity']
|
EXPORT_FIELDS = ['domain', 'severity']
|
||||||
|
|
||||||
def sync_blocklists(conf: dict):
|
def sync_blocklists(conf: argparse.Namespace):
|
||||||
"""Sync instance blocklists from remote sources.
|
"""Sync instance blocklists from remote sources.
|
||||||
|
|
||||||
@param conf: A configuration dictionary
|
@param conf: A configuration dictionary
|
||||||
|
@ -69,6 +72,12 @@ def sync_blocklists(conf: dict):
|
||||||
|
|
||||||
# Merge blocklists into an update dict
|
# Merge blocklists into an update dict
|
||||||
merged = merge_blocklists(blocklists, conf.mergeplan)
|
merged = merge_blocklists(blocklists, conf.mergeplan)
|
||||||
|
|
||||||
|
# Remove items listed in allowlists, if any
|
||||||
|
allowlists = fetch_allowlists(conf)
|
||||||
|
merged = apply_allowlists(merged, conf, allowlists)
|
||||||
|
|
||||||
|
# Save the final mergelist, if requested
|
||||||
if conf.blocklist_savefile:
|
if conf.blocklist_savefile:
|
||||||
log.info(f"Saving merged blocklist to {conf.blocklist_savefile}")
|
log.info(f"Saving merged blocklist to {conf.blocklist_savefile}")
|
||||||
save_blocklist_to_file(merged.values(), conf.blocklist_savefile, export_fields)
|
save_blocklist_to_file(merged.values(), conf.blocklist_savefile, export_fields)
|
||||||
|
@ -82,6 +91,35 @@ def sync_blocklists(conf: dict):
|
||||||
max_followed_severity = BlockSeverity(dest.get('max_followed_severity', 'silence'))
|
max_followed_severity = BlockSeverity(dest.get('max_followed_severity', 'silence'))
|
||||||
push_blocklist(token, domain, merged.values(), conf.dryrun, import_fields, max_followed_severity)
|
push_blocklist(token, domain, merged.values(), conf.dryrun, import_fields, max_followed_severity)
|
||||||
|
|
||||||
|
def apply_allowlists(merged: dict, conf: argparse.Namespace, allowlists: dict):
|
||||||
|
"""Apply allowlists
|
||||||
|
"""
|
||||||
|
# Apply allows specified on the commandline
|
||||||
|
for domain in conf.allow_domains:
|
||||||
|
log.info(f"'{domain}' allowed by commandline, removing any blocks...")
|
||||||
|
if domain in merged:
|
||||||
|
del merged[domain]
|
||||||
|
|
||||||
|
# Apply allows from URLs lists
|
||||||
|
log.info("Removing domains from URL allowlists...")
|
||||||
|
for key, alist in allowlists.items():
|
||||||
|
log.debug(f"Processing allows from '{key}'...")
|
||||||
|
for allowed in alist:
|
||||||
|
domain = allowed.domain
|
||||||
|
log.debug(f"Removing allowlisted domain '{domain}' from merged list.")
|
||||||
|
if domain in merged:
|
||||||
|
del merged[domain]
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def fetch_allowlists(conf: argparse.Namespace) -> dict:
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
if conf.allowlist_url_sources:
|
||||||
|
allowlists = fetch_from_urls({}, conf.allowlist_url_sources, ALLOWLIST_IMPORT_FIELDS)
|
||||||
|
return allowlists
|
||||||
|
return {}
|
||||||
|
|
||||||
def fetch_from_urls(blocklists: dict, url_sources: dict,
|
def fetch_from_urls(blocklists: dict, url_sources: dict,
|
||||||
import_fields: list=IMPORT_FIELDS,
|
import_fields: list=IMPORT_FIELDS,
|
||||||
save_intermediate: bool=False,
|
save_intermediate: bool=False,
|
||||||
|
@ -126,6 +164,8 @@ def fetch_from_instances(blocklists: dict, sources: dict,
|
||||||
domain = item['domain']
|
domain = item['domain']
|
||||||
admin = item.get('admin', False)
|
admin = item.get('admin', False)
|
||||||
token = item.get('token', None)
|
token = item.get('token', None)
|
||||||
|
itemsrc = f"https://{domain}/api"
|
||||||
|
|
||||||
# If import fields are provided, they override the global ones passed in
|
# If import fields are provided, they override the global ones passed in
|
||||||
source_import_fields = item.get('import_fields', None)
|
source_import_fields = item.get('import_fields', None)
|
||||||
if source_import_fields:
|
if source_import_fields:
|
||||||
|
@ -133,16 +173,19 @@ def fetch_from_instances(blocklists: dict, sources: dict,
|
||||||
import_fields = IMPORT_FIELDS.extend(source_import_fields)
|
import_fields = IMPORT_FIELDS.extend(source_import_fields)
|
||||||
|
|
||||||
# Add the blocklist with the domain as the source key
|
# Add the blocklist with the domain as the source key
|
||||||
blocklists[domain] = fetch_instance_blocklist(domain, token, admin, import_fields)
|
blocklists[itemsrc] = fetch_instance_blocklist(domain, token, admin, import_fields)
|
||||||
if save_intermediate:
|
if save_intermediate:
|
||||||
save_intermediate_blocklist(blocklists[domain], domain, savedir, export_fields)
|
save_intermediate_blocklist(blocklists[itemsrc], domain, savedir, export_fields)
|
||||||
return blocklists
|
return blocklists
|
||||||
|
|
||||||
def merge_blocklists(blocklists: dict, mergeplan: str='max') -> dict:
|
def merge_blocklists(blocklists: dict, mergeplan: str='max') -> dict:
|
||||||
"""Merge fetched remote blocklists into a bulk update
|
"""Merge fetched remote blocklists into a bulk update
|
||||||
|
@param blocklists: A dict of lists of DomainBlocks, keyed by source.
|
||||||
|
Each value is a list of DomainBlocks
|
||||||
@param mergeplan: An optional method of merging overlapping block definitions
|
@param mergeplan: An optional method of merging overlapping block definitions
|
||||||
'max' (the default) uses the highest severity block found
|
'max' (the default) uses the highest severity block found
|
||||||
'min' uses the lowest severity block found
|
'min' uses the lowest severity block found
|
||||||
|
@param returns: A dict of DomainBlocks keyed by domain
|
||||||
"""
|
"""
|
||||||
merged = {}
|
merged = {}
|
||||||
|
|
||||||
|
@ -433,7 +476,7 @@ def update_known_block(token: str, host: str, block: DomainBlock):
|
||||||
|
|
||||||
response = requests.put(url,
|
response = requests.put(url,
|
||||||
headers=requests_headers(token),
|
headers=requests_headers(token),
|
||||||
data=blockdata,
|
json=blockdata._asdict(),
|
||||||
timeout=REQUEST_TIMEOUT
|
timeout=REQUEST_TIMEOUT
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
@ -442,14 +485,14 @@ def update_known_block(token: str, host: str, block: DomainBlock):
|
||||||
def add_block(token: str, host: str, blockdata: DomainBlock):
|
def add_block(token: str, host: str, blockdata: DomainBlock):
|
||||||
"""Block a domain on Mastodon host
|
"""Block a domain on Mastodon host
|
||||||
"""
|
"""
|
||||||
log.debug(f"Blocking domain {blockdata.domain} at {host}...")
|
log.debug(f"Adding block entry for {blockdata.domain} at {host}...")
|
||||||
api_path = "/api/v1/admin/domain_blocks"
|
api_path = "/api/v1/admin/domain_blocks"
|
||||||
|
|
||||||
url = f"https://{host}{api_path}"
|
url = f"https://{host}{api_path}"
|
||||||
|
|
||||||
response = requests.post(url,
|
response = requests.post(url,
|
||||||
headers=requests_headers(token),
|
headers=requests_headers(token),
|
||||||
data=blockdata._asdict(),
|
json=blockdata._asdict(),
|
||||||
timeout=REQUEST_TIMEOUT
|
timeout=REQUEST_TIMEOUT
|
||||||
)
|
)
|
||||||
if response.status_code == 422:
|
if response.status_code == 422:
|
||||||
|
@ -515,6 +558,8 @@ def push_blocklist(token: str, host: str, blocklist: list[dict],
|
||||||
log.info(f"Pushing new block definition: {newblock}")
|
log.info(f"Pushing new block definition: {newblock}")
|
||||||
blockdata = oldblock.copy()
|
blockdata = oldblock.copy()
|
||||||
blockdata.update(newblock)
|
blockdata.update(newblock)
|
||||||
|
log.debug(f"Block as dict: {blockdata._asdict()}")
|
||||||
|
|
||||||
if not dryrun:
|
if not dryrun:
|
||||||
update_known_block(token, host, blockdata)
|
update_known_block(token, host, blockdata)
|
||||||
# add a pause here so we don't melt the instance
|
# add a pause here so we don't melt the instance
|
||||||
|
@ -530,6 +575,7 @@ def push_blocklist(token: str, host: str, blocklist: list[dict],
|
||||||
# This is a new block for the target instance, so we
|
# This is a new block for the target instance, so we
|
||||||
# need to add a block rather than update an existing one
|
# need to add a block rather than update an existing one
|
||||||
log.info(f"Adding new block: {newblock}...")
|
log.info(f"Adding new block: {newblock}...")
|
||||||
|
log.debug(f"Block as dict: {newblock._asdict()}")
|
||||||
|
|
||||||
# Make sure the new block doesn't clobber a domain with followers
|
# Make sure the new block doesn't clobber a domain with followers
|
||||||
newblock.severity = check_followed_severity(host, token, newblock.domain, newblock.severity, max_followed_severity)
|
newblock.severity = check_followed_severity(host, token, newblock.domain, newblock.severity, max_followed_severity)
|
||||||
|
@ -587,8 +633,15 @@ def save_blocklist_to_file(
|
||||||
for item in blocklist:
|
for item in blocklist:
|
||||||
writer.writerow(item._asdict())
|
writer.writerow(item._asdict())
|
||||||
|
|
||||||
def augment_args(args):
|
def augment_args(args, tomldata: str=None):
|
||||||
"""Augment commandline arguments with config file parameters"""
|
"""Augment commandline arguments with config file parameters
|
||||||
|
|
||||||
|
If tomldata is provided, uses that data instead of loading
|
||||||
|
from a config file.
|
||||||
|
"""
|
||||||
|
if tomldata:
|
||||||
|
conf = toml.loads(tomldata)
|
||||||
|
else:
|
||||||
conf = toml.load(args.config)
|
conf = toml.load(args.config)
|
||||||
|
|
||||||
if not args.no_fetch_url:
|
if not args.no_fetch_url:
|
||||||
|
@ -615,14 +668,19 @@ def augment_args(args):
|
||||||
if not args.import_fields:
|
if not args.import_fields:
|
||||||
args.import_fields = conf.get('import_fields', [])
|
args.import_fields = conf.get('import_fields', [])
|
||||||
|
|
||||||
args.blocklist_url_sources = conf.get('blocklist_url_sources')
|
if not args.mergeplan:
|
||||||
args.blocklist_instance_sources = conf.get('blocklist_instance_sources')
|
args.mergeplan = conf.get('mergeplan', 'max')
|
||||||
args.blocklist_instance_destinations = conf.get('blocklist_instance_destinations')
|
|
||||||
|
args.blocklist_url_sources = conf.get('blocklist_url_sources', [])
|
||||||
|
args.blocklist_instance_sources = conf.get('blocklist_instance_sources', [])
|
||||||
|
args.allowlist_url_sources = conf.get('allowlist_url_sources', [])
|
||||||
|
args.blocklist_instance_destinations = conf.get('blocklist_instance_destinations', [])
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
def main():
|
def setup_argparse():
|
||||||
|
"""Setup the commandline arguments
|
||||||
|
"""
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description="Bulk blocklist tool",
|
description="Bulk blocklist tool",
|
||||||
epilog=f"Part of FediBlockHole v{__version__}",
|
epilog=f"Part of FediBlockHole v{__version__}",
|
||||||
|
@ -633,10 +691,11 @@ def main():
|
||||||
ap.add_argument('-o', '--outfile', dest="blocklist_savefile", help="Save merged blocklist to a local file.")
|
ap.add_argument('-o', '--outfile', dest="blocklist_savefile", help="Save merged blocklist to a local file.")
|
||||||
ap.add_argument('-S', '--save-intermediate', dest="save_intermediate", action='store_true', help="Save intermediate blocklists we fetch to local files.")
|
ap.add_argument('-S', '--save-intermediate', dest="save_intermediate", action='store_true', help="Save intermediate blocklists we fetch to local files.")
|
||||||
ap.add_argument('-D', '--savedir', dest="savedir", help="Directory path to save intermediate lists.")
|
ap.add_argument('-D', '--savedir', dest="savedir", help="Directory path to save intermediate lists.")
|
||||||
ap.add_argument('-m', '--mergeplan', choices=['min', 'max'], default='max', help="Set mergeplan.")
|
ap.add_argument('-m', '--mergeplan', choices=['min', 'max'], help="Set mergeplan.")
|
||||||
|
|
||||||
ap.add_argument('-I', '--import-field', dest='import_fields', action='append', help="Extra blocklist fields to import.")
|
ap.add_argument('-I', '--import-field', dest='import_fields', action='append', help="Extra blocklist fields to import.")
|
||||||
ap.add_argument('-E', '--export-field', dest='export_fields', action='append', help="Extra blocklist fields to export.")
|
ap.add_argument('-E', '--export-field', dest='export_fields', action='append', help="Extra blocklist fields to export.")
|
||||||
|
ap.add_argument('-A', '--allow', dest="allow_domains", action='append', default=[], help="Override any blocks to allow this domain.")
|
||||||
|
|
||||||
ap.add_argument('--no-fetch-url', dest='no_fetch_url', action='store_true', help="Don't fetch from URLs, even if configured.")
|
ap.add_argument('--no-fetch-url', dest='no_fetch_url', action='store_true', help="Don't fetch from URLs, even if configured.")
|
||||||
ap.add_argument('--no-fetch-instance', dest='no_fetch_instance', action='store_true', help="Don't fetch from instances, even if configured.")
|
ap.add_argument('--no-fetch-instance', dest='no_fetch_instance', action='store_true', help="Don't fetch from instances, even if configured.")
|
||||||
|
@ -645,7 +704,13 @@ def main():
|
||||||
ap.add_argument('--loglevel', choices=['debug', 'info', 'warning', 'error', 'critical'], help="Set log output level.")
|
ap.add_argument('--loglevel', choices=['debug', 'info', 'warning', 'error', 'critical'], help="Set log output level.")
|
||||||
ap.add_argument('--dryrun', action='store_true', help="Don't actually push updates, just show what would happen.")
|
ap.add_argument('--dryrun', action='store_true', help="Don't actually push updates, just show what would happen.")
|
||||||
|
|
||||||
|
return ap
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
ap = setup_argparse()
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if args.loglevel is not None:
|
if args.loglevel is not None:
|
||||||
levelname = args.loglevel.upper()
|
levelname = args.loglevel.upper()
|
||||||
log.setLevel(getattr(logging, levelname))
|
log.setLevel(getattr(logging, levelname))
|
||||||
|
|
|
@ -97,9 +97,10 @@ class BlocklistParserCSV(BlocklistParser):
|
||||||
origitem = blockitem.copy()
|
origitem = blockitem.copy()
|
||||||
for key in origitem:
|
for key in origitem:
|
||||||
if key not in self.import_fields:
|
if key not in self.import_fields:
|
||||||
|
log.debug(f"ignoring field '{key}'")
|
||||||
del blockitem[key]
|
del blockitem[key]
|
||||||
|
|
||||||
# Convert dict to NamedTuple with the double-star operator
|
# Convert dict to DomainBlock with the double-star operator
|
||||||
# See: https://docs.python.org/3/tutorial/controlflow.html#tut-unpacking-arguments
|
# See: https://docs.python.org/3/tutorial/controlflow.html#tut-unpacking-arguments
|
||||||
block = DomainBlock(**blockitem)
|
block = DomainBlock(**blockitem)
|
||||||
if block.severity > self.max_severity:
|
if block.severity > self.max_severity:
|
||||||
|
@ -162,7 +163,7 @@ def str2bool(boolstring: str) -> bool:
|
||||||
boolstring = boolstring.lower()
|
boolstring = boolstring.lower()
|
||||||
if boolstring in ['true', 't', '1', 'y', 'yes']:
|
if boolstring in ['true', 't', '1', 'y', 'yes']:
|
||||||
return True
|
return True
|
||||||
elif boolstring in ['false', 'f', '0', 'n', 'no']:
|
elif boolstring in ['', 'false', 'f', '0', 'n', 'no']:
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Cannot parse value '{boolstring}' as boolean")
|
raise ValueError(f"Cannot parse value '{boolstring}' as boolean")
|
||||||
|
@ -183,4 +184,5 @@ def parse_blocklist(
|
||||||
"""Parse a blocklist in the given format
|
"""Parse a blocklist in the given format
|
||||||
"""
|
"""
|
||||||
parser = FORMAT_PARSERS[format](import_fields, max_severity)
|
parser = FORMAT_PARSERS[format](import_fields, max_severity)
|
||||||
|
log.debug(f"parsing {format} blocklist with import_fields: {import_fields}...")
|
||||||
return parser.parse_blocklist(blockdata)
|
return parser.parse_blocklist(blockdata)
|
|
@ -127,13 +127,13 @@ class DomainBlock(object):
|
||||||
"""Initialize the DomainBlock
|
"""Initialize the DomainBlock
|
||||||
"""
|
"""
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
|
self.severity = severity
|
||||||
self.public_comment = public_comment
|
self.public_comment = public_comment
|
||||||
self.private_comment = private_comment
|
self.private_comment = private_comment
|
||||||
self.reject_media = reject_media
|
self.reject_media = reject_media
|
||||||
self.reject_reports = reject_reports
|
self.reject_reports = reject_reports
|
||||||
self.obfuscate = obfuscate
|
self.obfuscate = obfuscate
|
||||||
self.id = id
|
self.id = id
|
||||||
self.severity = severity
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def severity(self):
|
def severity(self):
|
||||||
|
@ -146,17 +146,12 @@ class DomainBlock(object):
|
||||||
else:
|
else:
|
||||||
self._severity = BlockSeverity(sev)
|
self._severity = BlockSeverity(sev)
|
||||||
|
|
||||||
# Suspend implies reject_media,reject_reports == True
|
|
||||||
if self._severity.level == SeverityLevel.SUSPEND:
|
|
||||||
self.reject_media = True
|
|
||||||
self.reject_reports = True
|
|
||||||
|
|
||||||
def _asdict(self):
|
def _asdict(self):
|
||||||
"""Return a dict version of this object
|
"""Return a dict version of this object
|
||||||
"""
|
"""
|
||||||
dictval = {
|
dictval = {
|
||||||
'domain': self.domain,
|
'domain': self.domain,
|
||||||
'severity': self.severity,
|
'severity': str(self.severity),
|
||||||
'public_comment': self.public_comment,
|
'public_comment': self.public_comment,
|
||||||
'private_comment': self.private_comment,
|
'private_comment': self.private_comment,
|
||||||
'reject_media': self.reject_media,
|
'reject_media': self.reject_media,
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.join(os.path.dirname(__file__), 'helpers'))
|
|
@ -0,0 +1,11 @@
|
||||||
|
""" Utility functions for tests
|
||||||
|
"""
|
||||||
|
from fediblockhole import setup_argparse, augment_args
|
||||||
|
|
||||||
|
def shim_argparse(testargv: list=[], tomldata: str=None):
|
||||||
|
"""Helper function to parse test args
|
||||||
|
"""
|
||||||
|
ap = setup_argparse()
|
||||||
|
args = ap.parse_args(testargv)
|
||||||
|
args = augment_args(args, tomldata)
|
||||||
|
return args
|
|
@ -0,0 +1,49 @@
|
||||||
|
""" Test allowlists
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from util import shim_argparse
|
||||||
|
from fediblockhole.const import DomainBlock
|
||||||
|
from fediblockhole import fetch_allowlists, apply_allowlists
|
||||||
|
|
||||||
|
def test_cmdline_allow_removes_domain():
|
||||||
|
"""Test that -A <domain> removes entries from merged
|
||||||
|
"""
|
||||||
|
conf = shim_argparse(['-A', 'removeme.org'])
|
||||||
|
|
||||||
|
merged = {
|
||||||
|
'example.org': DomainBlock('example.org'),
|
||||||
|
'example2.org': DomainBlock('example.org'),
|
||||||
|
'removeme.org': DomainBlock('removeme.org'),
|
||||||
|
'keepblockingme.org': DomainBlock('keepblockingme.org'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# allowlists = {
|
||||||
|
# 'testlist': [ DomainBlock('removeme.org', 'noop'), ]
|
||||||
|
# }
|
||||||
|
|
||||||
|
merged = apply_allowlists(merged, conf, {})
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
merged['removeme.org']
|
||||||
|
|
||||||
|
def test_allowlist_removes_domain():
|
||||||
|
"""Test that an item in an allowlist removes entries from merged
|
||||||
|
"""
|
||||||
|
conf = shim_argparse()
|
||||||
|
|
||||||
|
merged = {
|
||||||
|
'example.org': DomainBlock('example.org'),
|
||||||
|
'example2.org': DomainBlock('example.org'),
|
||||||
|
'removeme.org': DomainBlock('removeme.org'),
|
||||||
|
'keepblockingme.org': DomainBlock('keepblockingme.org'),
|
||||||
|
}
|
||||||
|
|
||||||
|
allowlists = {
|
||||||
|
'testlist': [ DomainBlock('removeme.org', 'noop'), ]
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = apply_allowlists(merged, conf, allowlists)
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
merged['removeme.org']
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Test the commandline defined parameters correctly
|
||||||
|
"""
|
||||||
|
from util import shim_argparse
|
||||||
|
from fediblockhole import setup_argparse, augment_args
|
||||||
|
|
||||||
|
def test_cmdline_no_configfile():
|
||||||
|
""" Test bare command with no configfile
|
||||||
|
"""
|
||||||
|
ap = setup_argparse()
|
||||||
|
args = ap.parse_args([])
|
||||||
|
|
||||||
|
assert args.config == '/etc/default/fediblockhole.conf.toml'
|
||||||
|
assert args.mergeplan == None
|
||||||
|
assert args.blocklist_savefile == None
|
||||||
|
assert args.save_intermediate == False
|
||||||
|
assert args.savedir == None
|
||||||
|
assert args.import_fields == None
|
||||||
|
assert args.export_fields == None
|
||||||
|
|
||||||
|
assert args.no_fetch_url == False
|
||||||
|
assert args.no_fetch_instance == False
|
||||||
|
assert args.no_push_instance == False
|
||||||
|
assert args.dryrun == False
|
||||||
|
|
||||||
|
assert args.loglevel == None
|
||||||
|
|
||||||
|
def test_cmdline_mergeplan_min():
|
||||||
|
""" Test setting mergeplan min
|
||||||
|
"""
|
||||||
|
ap = setup_argparse()
|
||||||
|
args = ap.parse_args(['-m', 'min'])
|
||||||
|
|
||||||
|
assert args.mergeplan == 'min'
|
||||||
|
|
||||||
|
def test_set_allow_domain():
|
||||||
|
"""Set a single allow domain on commandline"""
|
||||||
|
ap = setup_argparse()
|
||||||
|
args = ap.parse_args(['-A', 'example.org'])
|
||||||
|
|
||||||
|
assert args.allow_domains == ['example.org']
|
||||||
|
|
||||||
|
def test_set_multiple_allow_domains():
|
||||||
|
"""Set multiple allow domains on commandline"""
|
||||||
|
ap = setup_argparse()
|
||||||
|
args = ap.parse_args(['-A', 'example.org', '-A', 'example2.org', '-A', 'example3.org'])
|
||||||
|
|
||||||
|
assert args.allow_domains == ['example.org', 'example2.org', 'example3.org']
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Test the config file is loading parameters correctly
|
||||||
|
"""
|
||||||
|
from util import shim_argparse
|
||||||
|
from fediblockhole import setup_argparse, augment_args
|
||||||
|
|
||||||
|
def test_parse_tomldata():
|
||||||
|
tomldata = """
|
||||||
|
# Test TOML config for FediBlockHole
|
||||||
|
|
||||||
|
blocklist_instance_sources = []
|
||||||
|
|
||||||
|
blocklist_url_sources = []
|
||||||
|
|
||||||
|
save_intermediate = true
|
||||||
|
|
||||||
|
import_fields = ['public_comment']
|
||||||
|
"""
|
||||||
|
ap = setup_argparse()
|
||||||
|
args = ap.parse_args([])
|
||||||
|
args = augment_args(args, tomldata)
|
||||||
|
|
||||||
|
assert args.blocklist_instance_sources == []
|
||||||
|
assert args.blocklist_url_sources == []
|
||||||
|
assert args.save_intermediate == True
|
||||||
|
assert args.import_fields == ['public_comment']
|
||||||
|
|
||||||
|
def test_set_mergeplan_max():
|
||||||
|
tomldata = """mergeplan = 'max'
|
||||||
|
"""
|
||||||
|
args = shim_argparse([], tomldata)
|
||||||
|
|
||||||
|
assert args.mergeplan == 'max'
|
||||||
|
|
||||||
|
def test_set_mergeplan_min():
|
||||||
|
tomldata = """mergeplan = 'min'
|
||||||
|
"""
|
||||||
|
args = shim_argparse([], tomldata)
|
||||||
|
|
||||||
|
assert args.mergeplan == 'min'
|
||||||
|
|
||||||
|
def test_set_allowlists():
|
||||||
|
tomldata = """# Comment on config
|
||||||
|
allowlist_url_sources = [ { url='file:///path/to/allowlist', format='csv'} ]
|
||||||
|
"""
|
||||||
|
args = shim_argparse([], tomldata)
|
||||||
|
|
||||||
|
assert args.mergeplan == 'max'
|
||||||
|
assert args.allowlist_url_sources == [{
|
||||||
|
'url': 'file:///path/to/allowlist',
|
||||||
|
'format': 'csv',
|
||||||
|
}]
|
|
@ -72,12 +72,3 @@ def test_compare_diff_sevs_2():
|
||||||
b = DomainBlock('example1.org', 'noop')
|
b = DomainBlock('example1.org', 'noop')
|
||||||
|
|
||||||
assert a != b
|
assert a != b
|
||||||
|
|
||||||
def test_suspend_rejects():
|
|
||||||
"""A suspend should reject_media and reject_reports
|
|
||||||
"""
|
|
||||||
a = DomainBlock('example.org', 'suspend')
|
|
||||||
|
|
||||||
assert a.severity.level == SeverityLevel.SUSPEND
|
|
||||||
assert a.reject_media == True
|
|
||||||
assert a.reject_reports == True
|
|
Loading…
Reference in New Issue