diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 783cf3e..3247111 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -692,14 +692,27 @@ def config_datasource(name): rows = [] for bl in cfg.get('dns_blocking', {}).get('blocklists', []): 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', '')) - try: - with open(bl_path) as f: - row['domain_count'] = str(sum(1 for _ in f)) - row['last_updated'] = fmt_timestamp(int(os.path.getmtime(bl_path))) - except Exception: - row['domain_count'] = '-' + 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: + with open(bl_path) as f: + row['domain_count'] = str(sum(1 for _ in f)) + row['last_updated'] = fmt_timestamp(int(os.path.getmtime(bl_path))) + except Exception: + row['domain_count'] = '-' + row['last_updated'] = '-' rows.append(row) return rows diff --git a/docker/routlin-dash/app/pages/dnsblocking/action.py b/docker/routlin-dash/app/pages/dnsblocking/action.py index 9f200d0..89ecefb 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/action.py +++ b/docker/routlin-dash/app/pages/dnsblocking/action.py @@ -13,8 +13,6 @@ _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) -_VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS)) - def _row_index(): try: @@ -30,29 +28,45 @@ def _hash_ok(): 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('_') - 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(): + bl_type = sanitize.filtervalue(request.form.get('bl_type', ''), {'community', 'local'}) name = sanitize.name(request.form.get('name', '')) 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: flash('The configuration has not been saved because a name is required.', 'error') 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: flash('The configuration has not been saved because a URL is required.', 'error') return None, True - if not fmt: - 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 + return {'name': name, 'description': description, 'bl_type': 'community', 'url': url}, None @bp.route('/action/dnsblocking/blocklists_delete', methods=['POST']) @@ -108,18 +122,36 @@ def blocklists_edit(): 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) if err: flash(err, 'error') return redirect(f'/{_PAGE}') - items[idx].update({ - 'name': fields['name'], - 'description': fields['description'], - 'format': fields['format'], - 'url': fields['url'], - }) + 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({ + 'name': fields['name'], + 'description': fields['description'], + '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'], + }) + 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) if errors: for msg in errors: @@ -143,19 +175,32 @@ def addblocklist_add(): cfg = config_utils.load_config() 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']) if err: flash(err, 'error') return redirect(f'/{_PAGE}') - entry = { - 'name': fields['name'], - 'description': fields['description'], - 'format': fields['format'], - 'url': fields['url'], - 'save_as': _save_as_from_name(fields['name']), - } + 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 = { + 'name': fields['name'], + 'description': fields['description'], + 'bl_type': 'local', + 'save_as': save_as, + } + 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) errors = validate.validate_config(cfg) if errors: diff --git a/docker/routlin-dash/app/pages/dnsblocking/content.json b/docker/routlin-dash/app/pages/dnsblocking/content.json index b2e48f1..335c0dc 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/content.json +++ b/docker/routlin-dash/app/pages/dnsblocking/content.json @@ -28,9 +28,9 @@ "field": "description" }, { - "label": "Format", - "field": "format", - "class": "col-mono" + "label": "Type", + "field": "bl_type_label", + "class": "col-narrow" }, { "label": "Source URL", @@ -77,7 +77,8 @@ "name": "name", "input_type": "text", "validate": "VALIDATION_DASH_NAME", - "placeholder": "e.g. steven-black" + "placeholder": "e.g. steven-black", + "existing_ids": "%BLOCKLIST_EXISTING_NAMES_JS%" }, { "type": "field", @@ -86,12 +87,28 @@ "input_type": "text", "placeholder": "e.g. Steven Black (ads, malware, trackers)" }, + { + "type": "raw_html", + "html": "
" + }, { "type": "field", - "label": "Format", - "name": "format", + "label": "Type", + "name": "bl_type", "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": "
" + }, + { + "type": "raw_html", + "html": "
" }, { "type": "field", @@ -99,16 +116,39 @@ "name": "url", "input_type": "text", "validate": "VALIDATION_URL", - "placeholder": "https://..." + "placeholder": "https://...", + "optional": true + }, + { + "type": "raw_html", + "html": "
" + }, + { + "type": "raw_html", + "html": "
" + }, + { + "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": "
" }, { "type": "button_row", "items": [ { "type": "button_primary", - "action": "/action/dnsblocking/addblocklist_add", - "method": "post", - "text": "Add Blocklist" + "class": "add-blocklist-btn", + "text": "Add Blocklist", + "disabled": "true" }, { "type": "button_cancel", diff --git a/docker/routlin-dash/app/pages/dnsblocking/view.py b/docker/routlin-dash/app/pages/dnsblocking/view.py index 6f3cfb9..666d19f 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/view.py +++ b/docker/routlin-dash/app/pages/dnsblocking/view.py @@ -7,6 +7,8 @@ import factory DNS_LOG_FILE = f'{config_utils.CONFIGS_DIR}/dns-blocklists.log' DNS_LOG_MAX = 50 +BL_TYPE_LABELS = {'community': 'Community', 'local': 'Local'} + def _dnsblocking_log_tail(cfg): try: @@ -37,20 +39,30 @@ def _dnsblocking_log_tail(cfg): def blocklist_stats_html(cfg): rows = '' 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', '') bl_path = f'{config_utils.BLOCKLISTS_DIR}/{save_as}' if save_as else '' - try: - with open(bl_path) as f: - entries = sum(1 for _ in f) - mtime = int(os.path.getmtime(bl_path)) - size_str = config_utils.fmt_bytes(os.path.getsize(bl_path)) - last_refreshed = ( - f'{datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")}' - f' ({config_utils.relative_time(mtime, datetime.now(tz=timezone.utc).timestamp())} ago)' - ) - except Exception: - entries, size_str, last_refreshed = '-', '-', 'Never' + 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: + with open(bl_path) as f: + entries = sum(1 for _ in f) + mtime = int(os.path.getmtime(bl_path)) + size_str = config_utils.fmt_bytes(os.path.getsize(bl_path)) + last_refreshed = ( + f'{datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")}' + f' ({config_utils.relative_time(mtime, datetime.now(tz=timezone.utc).timestamp())} ago)' + ) + except Exception: + entries, size_str, last_refreshed = '-', '-', 'Never' rows += ( '' f'{name}' @@ -80,10 +92,8 @@ def collect_tokens(cfg): tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')) tokens['BLOCKLIST_STATS_HTML'] = blocklist_stats_html(cfg) tokens['DNS_LOG_TAIL'], tokens['DNS_LOG_SUMMARY'] = _dnsblocking_log_tail(cfg) - tokens['BLOCKLIST_FORMAT_OPTIONS'] = json.dumps([ - {'value': 'hosts', 'label': 'hosts (hosts file format)'}, - {'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'}, - ]) + blocklists = cfg.get('dns_blocking', {}).get('blocklists', []) + tokens['BLOCKLIST_EXISTING_NAMES_JS'] = json.dumps([bl.get('name', '') for bl in blocklists]) content = factory.load_json(f'{factory.PAGES_DIR}/dnsblocking/content.json') for table_item in factory.iter_table_items(content.get('items', [])): ds = table_item.get('datasource', '') diff --git a/routlin/config.json b/routlin/config.json index 610942a..de97fc6 100644 --- a/routlin/config.json +++ b/routlin/config.json @@ -561,23 +561,23 @@ { "name": "oisd-big", "description": "OISD Big (ads, phishing, malware, telemetry)", + "bl_type": "community" "save_as": "oisd-big.conf", "url": "https://big.oisd.nl/dnsmasq2", - "format": "dnsmasq" }, { "name": "hagezi-light", "description": "Hagezi Light (ads, tracking, metrics, badware)", + "bl_type": "community" "save_as": "hagezi-light.conf", "url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/light.txt", - "format": "dnsmasq" }, { "name": "hagezi-pro-plus", "description": "Hagezi Pro Plus (ads, tracking, porn, gambling)", + "bl_type": "community" "save_as": "hagezi-pro-plus.conf", "url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/pro.plus.txt", - "format": "dnsmasq" } ] }, diff --git a/routlin/dns-blocklists.py b/routlin/dns-blocklists.py index 966fe9b..9957404 100644 --- a/routlin/dns-blocklists.py +++ b/routlin/dns-blocklists.py @@ -122,7 +122,21 @@ def parse_hosts_format(content): 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": return parse_dnsmasq_format(content) return parse_hosts_format(content) @@ -151,7 +165,10 @@ def download_all_blocklists(data): results = {} for name in needed: entry = bl_library[name] - url = entry["url"] + if entry.get("bl_type") == "local": + results[name] = (None, entry) + continue + url = entry["url"] try: req = urllib.request.Request(url, headers={"User-Agent": "dns-blocklists.py/1.0"}) with urllib.request.urlopen(req, timeout=30) as r: @@ -164,6 +181,15 @@ def download_all_blocklists(data): 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): BLOCKLIST_DIR.mkdir(exist_ok=True) @@ -172,12 +198,23 @@ def update_blocklists(data): domains_by_name = {} 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.") domains_by_name[name] = set() else: (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}'") domains_by_name[name] = domains @@ -208,7 +245,10 @@ def update_blocklists(data): f.unlink() 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