｢そうだ､自分でコントロールできる広告ブロック DNS キャッシュサーバを作ったらいいんだ!｣と思ってやった(途中)結果wwwwww

# はじめに
主に[ともちゃ](http://tomocha.moe/)さんの｢[Ad DNSブロッキングおよび、kawango DNSブロッキングの実装をしてみたら、超快適だった](https://tomocha.moe/diary/?201808b#201808182)｣や､[がとらぼ](https://intaa.net/)さんの｢[Unboundでネットワーク内まるごと広告ブロック](https://intaa.net/net/unbound_adblock)｣をだいたい~~丸パクリ~~参考にしています｡予めリンク先の記事を読んでいただければなんとなく何してるかわかるようになっています｡

# 概要
~~｢[またいつかの記事で](https://qiita.com/soprano1125/items/4412f40cd9603217b6d3)｣と書いてから約2年経ちました(ぉぃ~~
世の中広告やらでちょいちょい話題になりますが普段使っているブロッキングリストに見たいサイトが含まれていると手動でリストからせっせと外しに行かないといけなかったので､せっかくなら｢自分でリストを DB に取り込んで見たいサイトはテーブルの値をいじって見られるように[^ossan]したらいいんだ!｣と思いつきなんとなくやってみた(途中)結果です｡
｢あ､もちろん今年の例のアレということでw｣と書いていますが来週あたりか忘れた頃に [DNS Advent Calendar 2024](https://qiita.com/advent-calendar/2024/dns) へ載せちゃうかもしれません(ぇ

# 下準備
## 準備するもの
* Unbound
* MySQL や PostgreSQL や SQLite などの RDB
* public_suffix_list
* 広告ブロックするリスト

## ﾈﾀﾊﾞﾚ
```config:dns_block.conf
server:
    local-zone: "2ch.live" static
    local-zone: "2ch.net" transparent
    local-data: "ex14.2ch.net. 10 IN A 0.0.0.0"
    local-data: "ex14.2ch.net. 10 IN AAAA ::"
```
最終的にこういう感じのコンフィグを**手動**生成させます｡
ピンポイントで 0.0.0.0 にしているのは~~結構テキトーですが~~ NXDOMAIN と REFUSED にして普段使ってる Chrome で判別出来るようにするためです｡

## public_suffix_list の編集
端的に説明すると tld_list.dat という public_suffix_list.dat をコピーしたテキストファイルをエディタで開いて､ TLD だけのリストにします｡
public_suffix_list.dat をそのまま使うと AWS まとめてごっそりブロックしたいときなど private domain によってちょっと様子のおかしなリストになります｡

## テーブルの作成
```sql:table.sql
CREATE TABLE domain (
  domain varchar(255) NOT NULL DEFAULT 'example.com',
  mode varchar(255) NOT NULL DEFAULT 'static',
  update_date timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
  UNIQUE KEY domain(domain)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

CREATE TABLE host (
  host varchar(512) NOT NULL DEFAULT 'www.example.com',
  domain varchar(255) NOT NULL DEFAULT 'example.com',
  source varchar(255) NOT NULL DEFAULT 'none',
  disable bit(1) NOT NULL DEFAULT 0b0,
  update_date timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
  KEY domain(domain),
  UNIQUE KEY hosts(host, domain),
  UNIQUE KEY host(host),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
```
MySQL ~~というか MariaDB~~ で使う前提かつ割と命名が適当な SQL 文なので他の RDB で使う際はそのまま使わず参考程度にしてください｡
ブロックリストから各言語の public_suffix_list を扱えるモジュールを使って tld_list.dat を読みながら DB に追加するスクリプトはおまかせします(ぉぃ
~~『自分は｢ファイルが更新されていたらリストに追加する｣するため Perl + LWP::UserAgent で mirror メソッドを使うように実装してます』とポロッと口を滑らせておきます｡~~
だいたい､下記のような SQL 文を実行してデータが追加したり変更出来ていればよいかと｡
```sql:sample.sql
INSERT IGNORE INTO host(source, domain, host) VALUES('sample', 'example.com', 'www.example.com');

-- 'example.com' なホスト名をひとまとめにブロックするなら NXDOMAIN にする
INSERT IGNORE INTO domain(domain, mode) VALUES('example.com', 'static');

-- 'example.com' なホスト名で特定ホスト名だけピンポイントにブロックするなら 0.0.0.0 にする
INSERT IGNORE INTO domain(domain, mode) VALUES('example.com', 'transparent');

-- 'www.example.com' をブロック対象から外すなら 'host' テーブルの 'disable' カラムを 1 にする
UPDATE host SET disable = 0b1 WHERE host = 'www.example.com';
```

# 実装
## コンフィグ生成スクリプト
```python:generate_dns-block_conf
#!/usr/bin/python3

# unbound で使う dns_block.conf を生成

import sys
import os

import pymysql
import time
import difflib
import shutil

def main():
    try:

        lines = []

        out_file = '/etc/unbound/unbound.conf.d/dns_block.conf'
        tmp_file = os.path.join('/tmp/adblocker', f'dns_block-{int(time.time())}')

        print('** make tmpdir')
        os.makedirs(os.path.dirname(tmp_file), exist_ok=True)

        with pymysql.connect(read_default_file="~/conf/my.cnf") as conn:
            print('** import domain')
            with conn.cursor() as cur:
                cur.execute("INSERT IGNORE INTO domain(domain) SELECT domain FROM host GROUP BY domain;")
            conn.commit()

            print('** update domain mode')
            with conn.cursor() as cur:
                cur.execute("UPDATE domain SET mode = 'transparent' WHERE mode = '';")
            conn.commit()

            print('** generate list')
            with conn.cursor(pymysql.cursors.DictCursor) as dom_cur:
                dom_cur.execute("SELECT mode, domain FROM domain ORDER BY domain;")
                domains = dom_cur.fetchall()

                lines.append('server:\n')
                for domain in domains:
                    print(f"do domain:[{domain['domain']}] => {domain['mode']}")
                    lines.append('\tlocal-zone: "{}" {}\n'.format(domain['domain'], domain['mode']))

                    match(domain['mode']):
                        case 'transparent':
                            with conn.cursor(pymysql.cursors.DictCursor) as host_cur:
                                host_cur.execute(host_cur.mogrify("SELECT host,disable FROM host WHERE domain = %s ORDER BY host;", (domain['domain'])))
                                domain['hosts'] = host_cur.fetchall()

                        case 'redirect':
                            with conn.cursor(pymysql.cursors.DictCursor) as host_cur:
                                host_cur.execute(host_cur.mogrify("SELECT host,disable FROM host WHERE domain = %s ORDER BY host;", (domain['domain'])))
                                domain['hosts'] = host_cur.fetchall()

                        case _:
                            pass

                    try:

                        for host in domain['hosts']:
                            print(f"host-mode:[{host['host']}]:[{host['disable']}] => [{int(host['disable'].hex()) == 1}]")
                            if(int(host['disable'].hex()) == 1):
                                continue

                            lines.append('\tlocal-data: "{}. 10 IN {}"\n'.format(host['host'], 'A 0.0.0.0'))
                            lines.append('\tlocal-data: "{}. 10 IN {}"\n'.format(host['host'], 'AAAA ::'))

                    except KeyError:
                        pass

            print('** write tmp config')
            with open(tmp_file, mode='w', encoding='utf-8', newline='\n') as fp:
                fp.writelines(lines)

            print('** read config out_file')
            with open(out_file) as f:
                txt1 = f.readlines()

            print('** read config tmp_file')
            with open(tmp_file) as f:
                txt2 = f.readlines()

            print('** check config diff')
            res = difflib.ndiff(txt1, txt2)
            isModified = False
            for r in res:
                if r[0:1] in ['+', '-']:
                    print(r.strip())
                    isModified = True

            if not(isModified):
                print('!! not update DNS block config')
                return -1

            shutil.move(tmp_file, out_file)
            print('!! update DNS block config')
            return 0

    except OSError:
        pass

if __name__ == '__main__':
    main()
```
~~(説明とコードの)手抜きの極み!w~~
domain テーブルの 'mode' カラムにある｢static｣か｢transparent｣に応じてドメイン全体を NXDOMAIN で応答させるか特定ホスト名だけ 0.0.0.0 で応答させるようなコンフィグをここで生成しています｡
生成したコンフィグを Unbound に include して設定を読ませてあげて､ブラウジングすれば効果を実感出来ると思います｡
あ､生成したコンフィグのファイルパスは｢/etc/unbound/unbound.conf.d/dns_block.conf｣と固定しているので｢なんかへんだな｣と思ったら適当にいじっていただいても(ぉぃ
~~接続情報が書かれた my.cnf を読ませたら DB に接続してくれる pymysql はマジ最高!~~

# まとめ
こんな感じで｢ブロックリストに入ってるけど自分の環境で観たいサイト｣を観ることが出来るようになりました｡ ~~2020年12月上旬からこの方式でブロックリストを DB へ焚べていたら host テーブルが 1[GB] 超えてました…w ﾁｯｸｼｮｰ(コウメ太夫風~~
domain テーブルの 'mode' カラムで 'static' と 'transparent' を書き換えて切り替えるスクリプトを書いたり､ WebUI 作ったりすれば自由度の高い広告ブロッキングシステムができそうです[^jinriki]｡

最終的に Unbound の｢Python モジュール｣で判定させられたら面白そうですねー(と今後自分がやる､､､とは明言しないでおこうw

[^ossan]: おっさんなので｢Amazon Prime Video｣とか｢FAN○A｣とか｢@@@@@@@(自重｣とか｢*********(自ry｣とか(ぉぃ
[^jinriki]: 今は人力で書き換えてるけど別にこのままでもいいや派