Development

This commit is contained in:
Matthew Grotke 2026-06-09 00:32:42 -04:00
parent 114da3cd1c
commit 20061872d7
6 changed files with 216 additions and 68 deletions

View file

@ -692,7 +692,20 @@ def config_datasource(name):
rows = [] rows = []
for bl in cfg.get('dns_blocking', {}).get('blocklists', []): for bl in cfg.get('dns_blocking', {}).get('blocklists', []):
row = dict(bl) row = dict(bl)
bl_type = bl.get('bl_type', 'community')
row['bl_type_label'] = 'Local' if bl_type == 'local' else 'Community'
bl_path = os.path.join(BLOCKLISTS_DIR, bl.get('save_as', '')) bl_path = os.path.join(BLOCKLISTS_DIR, bl.get('save_as', ''))
if bl_type == 'local':
try:
with open(bl_path) as f:
content = f.read()
row['local_entries'] = content.strip()
row['domain_count'] = str(sum(1 for ln in content.splitlines() if ln.strip() and not ln.startswith('#')))
except Exception:
row['local_entries'] = ''
row['domain_count'] = '-'
row['last_updated'] = '-'
else:
try: try:
with open(bl_path) as f: with open(bl_path) as f:
row['domain_count'] = str(sum(1 for _ in f)) row['domain_count'] = str(sum(1 for _ in f))

View file

@ -13,8 +13,6 @@ _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__) bp = Blueprint(_PAGE, __name__)
_VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS))
def _row_index(): def _row_index():
try: try:
@ -30,29 +28,45 @@ def _hash_ok():
return True return True
def _save_as_from_name(name): def _save_as_from_name(name, ext):
slug = re.sub(r'[^a-z0-9_-]', '_', name.lower()).strip('_') slug = re.sub(r'[^a-z0-9_-]', '_', name.lower()).strip('_')
return f'{slug}.conf' return f'{slug}.{ext}'
def _write_local_file(save_as, lines):
"""Write domain list to blocklists dir. Returns error string or None."""
try:
bl_path = Path(config_utils.BLOCKLISTS_DIR) / save_as
bl_path.parent.mkdir(parents=True, exist_ok=True)
bl_path.write_text('\n'.join(lines))
except Exception as ex:
return str(ex)
return None
def _parse_fields(): def _parse_fields():
bl_type = sanitize.filtervalue(request.form.get('bl_type', ''), {'community', 'local'})
name = sanitize.name(request.form.get('name', '')) name = sanitize.name(request.form.get('name', ''))
description = sanitize.description(request.form.get('description', '')) description = sanitize.description(request.form.get('description', ''))
fmt = sanitize.filtervalue(request.form.get('format', ''), validate.VALID_BLOCKLIST_FORMATS)
url = sanitize.url(request.form.get('url', ''))
if not name: if not name:
flash('The configuration has not been saved because a name is required.', 'error') flash('The configuration has not been saved because a name is required.', 'error')
return None, True return None, True
if not bl_type:
flash('The configuration has not been saved because a type is required.', 'error')
return None, True
if bl_type == 'local':
raw = request.form.get('local_entries', '')
local_lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
return {'name': name, 'description': description, 'bl_type': 'local',
'local_lines': local_lines}, None
url = sanitize.url(request.form.get('url', ''))
if not url: if not url:
flash('The configuration has not been saved because a URL is required.', 'error') flash('The configuration has not been saved because a URL is required.', 'error')
return None, True return None, True
if not fmt: return {'name': name, 'description': description, 'bl_type': 'community', 'url': url}, None
flash(f'The configuration has not been saved because the format is invalid. '
f'Accepted formats: {_VALID_FORMATS_STR}.', 'error')
return None, True
return {'name': name, 'description': description, 'format': fmt, 'url': url}, None
@bp.route('/action/dnsblocking/blocklists_delete', methods=['POST']) @bp.route('/action/dnsblocking/blocklists_delete', methods=['POST'])
@ -108,18 +122,36 @@ def blocklists_edit():
before = copy.deepcopy(items[idx]) before = copy.deepcopy(items[idx])
# Blocklist name must be unique - it is the lookup key for VLAN use_blocklists references
err = validate.check_blocklist_name_unique(items, fields['name'], exclude_idx=idx) err = validate.check_blocklist_name_unique(items, fields['name'], exclude_idx=idx)
if err: if err:
flash(err, 'error') flash(err, 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
if fields['bl_type'] == 'local':
save_as = items[idx].get('save_as') or _save_as_from_name(fields['name'], 'txt')
write_err = _write_local_file(save_as, fields['local_lines'])
if write_err:
flash(f'Could not save local blocklist file: {write_err}', 'error')
return redirect(f'/{_PAGE}')
items[idx].update({ items[idx].update({
'name': fields['name'], 'name': fields['name'],
'description': fields['description'], 'description': fields['description'],
'format': fields['format'], 'bl_type': 'local',
'save_as': save_as,
})
items[idx].pop('format', None)
items[idx].pop('url', None)
else:
items[idx].update({
'name': fields['name'],
'description': fields['description'],
'bl_type': 'community',
'url': fields['url'], 'url': fields['url'],
}) })
if not items[idx].get('save_as'):
items[idx]['save_as'] = _save_as_from_name(fields['name'], 'conf')
items[idx].pop('local_lines', None)
errors = validate.validate_config(cfg) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
@ -143,19 +175,32 @@ def addblocklist_add():
cfg = config_utils.load_config() cfg = config_utils.load_config()
blocklists = cfg.setdefault('dns_blocking', {}).setdefault('blocklists', []) blocklists = cfg.setdefault('dns_blocking', {}).setdefault('blocklists', [])
# Blocklist name must be unique - it is the lookup key for VLAN use_blocklists references
err = validate.check_blocklist_name_unique(blocklists, fields['name']) err = validate.check_blocklist_name_unique(blocklists, fields['name'])
if err: if err:
flash(err, 'error') flash(err, 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
if fields['bl_type'] == 'local':
save_as = _save_as_from_name(fields['name'], 'txt')
write_err = _write_local_file(save_as, fields['local_lines'])
if write_err:
flash(f'Could not save local blocklist file: {write_err}', 'error')
return redirect(f'/{_PAGE}')
entry = { entry = {
'name': fields['name'], 'name': fields['name'],
'description': fields['description'], 'description': fields['description'],
'format': fields['format'], 'bl_type': 'local',
'url': fields['url'], 'save_as': save_as,
'save_as': _save_as_from_name(fields['name']),
} }
else:
entry = {
'name': fields['name'],
'description': fields['description'],
'bl_type': 'community',
'url': fields['url'],
'save_as': _save_as_from_name(fields['name'], 'conf'),
}
blocklists.append(entry) blocklists.append(entry)
errors = validate.validate_config(cfg) errors = validate.validate_config(cfg)
if errors: if errors:

View file

@ -28,9 +28,9 @@
"field": "description" "field": "description"
}, },
{ {
"label": "Format", "label": "Type",
"field": "format", "field": "bl_type_label",
"class": "col-mono" "class": "col-narrow"
}, },
{ {
"label": "Source URL", "label": "Source URL",
@ -77,7 +77,8 @@
"name": "name", "name": "name",
"input_type": "text", "input_type": "text",
"validate": "VALIDATION_DASH_NAME", "validate": "VALIDATION_DASH_NAME",
"placeholder": "e.g. steven-black" "placeholder": "e.g. steven-black",
"existing_ids": "%BLOCKLIST_EXISTING_NAMES_JS%"
}, },
{ {
"type": "field", "type": "field",
@ -86,12 +87,28 @@
"input_type": "text", "input_type": "text",
"placeholder": "e.g. Steven Black (ads, malware, trackers)" "placeholder": "e.g. Steven Black (ads, malware, trackers)"
}, },
{
"type": "raw_html",
"html": "<div id=\"type-row\">"
},
{ {
"type": "field", "type": "field",
"label": "Format", "label": "Type",
"name": "format", "name": "bl_type",
"input_type": "select", "input_type": "select",
"options": "%BLOCKLIST_FORMAT_OPTIONS%" "options": [
{"value": "", "label": "-- Select Type --"},
{"value": "community", "label": "Community Blocklist"},
{"value": "local", "label": "Local Blocklist"}
]
},
{
"type": "raw_html",
"html": "</div>"
},
{
"type": "raw_html",
"html": "<div id=\"community-fields\" style=\"display:none\">"
}, },
{ {
"type": "field", "type": "field",
@ -99,16 +116,39 @@
"name": "url", "name": "url",
"input_type": "text", "input_type": "text",
"validate": "VALIDATION_URL", "validate": "VALIDATION_URL",
"placeholder": "https://..." "placeholder": "https://...",
"optional": true
},
{
"type": "raw_html",
"html": "</div>"
},
{
"type": "raw_html",
"html": "<div id=\"local-fields\" style=\"display:none\">"
},
{
"type": "field",
"label": "Domains",
"name": "local_entries",
"input_type": "textarea",
"rows": 8,
"placeholder": "One domain per line, e.g.:\nads.example.com\ntracker.example.net",
"hint": "One domain per line. Subdomains are automatically blocked.",
"optional": true
},
{
"type": "raw_html",
"html": "</div>"
}, },
{ {
"type": "button_row", "type": "button_row",
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/dnsblocking/addblocklist_add", "class": "add-blocklist-btn",
"method": "post", "text": "Add Blocklist",
"text": "Add Blocklist" "disabled": "true"
}, },
{ {
"type": "button_cancel", "type": "button_cancel",

View file

@ -7,6 +7,8 @@ import factory
DNS_LOG_FILE = f'{config_utils.CONFIGS_DIR}/dns-blocklists.log' DNS_LOG_FILE = f'{config_utils.CONFIGS_DIR}/dns-blocklists.log'
DNS_LOG_MAX = 50 DNS_LOG_MAX = 50
BL_TYPE_LABELS = {'community': 'Community', 'local': 'Local'}
def _dnsblocking_log_tail(cfg): def _dnsblocking_log_tail(cfg):
try: try:
@ -38,8 +40,18 @@ def blocklist_stats_html(cfg):
rows = '' rows = ''
for bl in cfg.get('dns_blocking', {}).get('blocklists', []): for bl in cfg.get('dns_blocking', {}).get('blocklists', []):
name = factory.e(bl.get('name', '')) name = factory.e(bl.get('name', ''))
is_local = bl.get('bl_type') == 'local'
save_as = bl.get('save_as', '') save_as = bl.get('save_as', '')
bl_path = f'{config_utils.BLOCKLISTS_DIR}/{save_as}' if save_as else '' bl_path = f'{config_utils.BLOCKLISTS_DIR}/{save_as}' if save_as else ''
if is_local:
try:
with open(bl_path) as f:
entries = sum(1 for ln in f if ln.strip() and not ln.startswith('#'))
size_str = config_utils.fmt_bytes(os.path.getsize(bl_path))
last_refreshed = 'Local'
except Exception:
entries, size_str, last_refreshed = '-', '-', 'Local'
else:
try: try:
with open(bl_path) as f: with open(bl_path) as f:
entries = sum(1 for _ in f) entries = sum(1 for _ in f)
@ -80,10 +92,8 @@ def collect_tokens(cfg):
tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')) tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-'))
tokens['BLOCKLIST_STATS_HTML'] = blocklist_stats_html(cfg) tokens['BLOCKLIST_STATS_HTML'] = blocklist_stats_html(cfg)
tokens['DNS_LOG_TAIL'], tokens['DNS_LOG_SUMMARY'] = _dnsblocking_log_tail(cfg) tokens['DNS_LOG_TAIL'], tokens['DNS_LOG_SUMMARY'] = _dnsblocking_log_tail(cfg)
tokens['BLOCKLIST_FORMAT_OPTIONS'] = json.dumps([ blocklists = cfg.get('dns_blocking', {}).get('blocklists', [])
{'value': 'hosts', 'label': 'hosts (hosts file format)'}, tokens['BLOCKLIST_EXISTING_NAMES_JS'] = json.dumps([bl.get('name', '') for bl in blocklists])
{'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'},
])
content = factory.load_json(f'{factory.PAGES_DIR}/dnsblocking/content.json') content = factory.load_json(f'{factory.PAGES_DIR}/dnsblocking/content.json')
for table_item in factory.iter_table_items(content.get('items', [])): for table_item in factory.iter_table_items(content.get('items', [])):
ds = table_item.get('datasource', '') ds = table_item.get('datasource', '')

View file

@ -561,23 +561,23 @@
{ {
"name": "oisd-big", "name": "oisd-big",
"description": "OISD Big (ads, phishing, malware, telemetry)", "description": "OISD Big (ads, phishing, malware, telemetry)",
"bl_type": "community"
"save_as": "oisd-big.conf", "save_as": "oisd-big.conf",
"url": "https://big.oisd.nl/dnsmasq2", "url": "https://big.oisd.nl/dnsmasq2",
"format": "dnsmasq"
}, },
{ {
"name": "hagezi-light", "name": "hagezi-light",
"description": "Hagezi Light (ads, tracking, metrics, badware)", "description": "Hagezi Light (ads, tracking, metrics, badware)",
"bl_type": "community"
"save_as": "hagezi-light.conf", "save_as": "hagezi-light.conf",
"url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/light.txt", "url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/light.txt",
"format": "dnsmasq"
}, },
{ {
"name": "hagezi-pro-plus", "name": "hagezi-pro-plus",
"description": "Hagezi Pro Plus (ads, tracking, porn, gambling)", "description": "Hagezi Pro Plus (ads, tracking, porn, gambling)",
"bl_type": "community"
"save_as": "hagezi-pro-plus.conf", "save_as": "hagezi-pro-plus.conf",
"url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/pro.plus.txt", "url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/pro.plus.txt",
"format": "dnsmasq"
} }
] ]
}, },

View file

@ -122,7 +122,21 @@ def parse_hosts_format(content):
return domains return domains
def parse_blocklist(content, fmt): def detect_format(content):
for ln in content.splitlines():
ln = ln.strip()
if not ln or ln.startswith("#"):
continue
if ln.startswith("local=/") or ln.startswith("address=/"):
return "dnsmasq"
if ln[0].isdigit():
return "hosts"
return "dnsmasq"
def parse_blocklist(content, fmt=None):
if fmt is None:
fmt = detect_format(content)
if fmt == "dnsmasq": if fmt == "dnsmasq":
return parse_dnsmasq_format(content) return parse_dnsmasq_format(content)
return parse_hosts_format(content) return parse_hosts_format(content)
@ -151,6 +165,9 @@ def download_all_blocklists(data):
results = {} results = {}
for name in needed: for name in needed:
entry = bl_library[name] entry = bl_library[name]
if entry.get("bl_type") == "local":
results[name] = (None, entry)
continue
url = entry["url"] url = entry["url"]
try: try:
req = urllib.request.Request(url, headers={"User-Agent": "dns-blocklists.py/1.0"}) req = urllib.request.Request(url, headers={"User-Agent": "dns-blocklists.py/1.0"})
@ -164,6 +181,15 @@ def download_all_blocklists(data):
return results return results
def _parse_local_domains(content):
domains = set()
for ln in content.splitlines():
ln = ln.strip()
if ln and not ln.startswith("#"):
domains.add(ln)
return domains
def update_blocklists(data): def update_blocklists(data):
BLOCKLIST_DIR.mkdir(exist_ok=True) BLOCKLIST_DIR.mkdir(exist_ok=True)
@ -172,12 +198,23 @@ def update_blocklists(data):
domains_by_name = {} domains_by_name = {}
for name, (content, entry) in downloaded.items(): for name, (content, entry) in downloaded.items():
if content is None: if entry.get("bl_type") == "local":
save_as = entry.get("save_as", "")
local_file = BLOCKLIST_DIR / save_as if save_as else None
try:
local_content = local_file.read_text() if local_file else ""
domains = _parse_local_domains(local_content)
log.info(f"Local blocklist '{name}': {len(domains):,} domains")
except Exception as e:
log.error(f"Local blocklist '{name}' could not be read: {e}")
domains = set()
domains_by_name[name] = domains
elif content is None:
log.error(f"Blocklist '{name}' failed to download -- it will be skipped.") log.error(f"Blocklist '{name}' failed to download -- it will be skipped.")
domains_by_name[name] = set() domains_by_name[name] = set()
else: else:
(BLOCKLIST_DIR / entry["save_as"]).write_text(content) (BLOCKLIST_DIR / entry["save_as"]).write_text(content)
domains = parse_blocklist(content, entry.get("format", "dnsmasq")) domains = parse_blocklist(content)
log.info(f"Parsed {len(domains):,} domains from '{name}'") log.info(f"Parsed {len(domains):,} domains from '{name}'")
domains_by_name[name] = domains domains_by_name[name] = domains
@ -208,7 +245,10 @@ def update_blocklists(data):
f.unlink() f.unlink()
log.info(f"Removed stale merged file: {f.name}") log.info(f"Removed stale merged file: {f.name}")
any_failed = any(content is None for content, _ in downloaded.values()) any_failed = any(
content is None and entry.get("bl_type") != "local"
for content, entry in downloaded.values()
)
return not any_failed return not any_failed