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": "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