I was looking for a way to implement Web Filtering on GNU/Linux. I knew it was possible to use squidguard for this purpose, but it turned out to be quite complicated to set up and particularly difficult to deploy automatically on every workstation, especially when not managed by an Active Directory domain. That's when I came across the DynFi Open Source firewall: https://dynfi.com, which is capable of Web Filtering and uses RPZ: https://en.wikipedia.org/ to do so. So I started investigating this solution and found a way to implement Web Filtering with RPZ.
In this architecture, we will have a Debian serving as both a DNS and a web server. When a client request for a site that is forbidden (as per a pre-established blocklist), they will be redirected to the web server, where a blocked web page message will be displayed in their browsers.
As described above, we will have two services running on our Debian server: a web server to display a simple text message informing users that they are attempting to connect to a forbidden website, and a DNS service to provide clients with correct or modified DNS responses. For the web server, I will use micro-httpd, which is a lightweight HTTP server that perfectly suits our needs, and Unbound as the DNS server.
To inform users that the requested page is blocked, we will require a web server that will display a denied web page, informing them that the website they are trying to access is forbidden.
root@host:~# apt install micro-httpd
[Unit]
Description=micro-httpd
Documentation=man:micro-httpd(8)
[Service]
User=nobody
Group=www-data
ExecStart=-/usr/sbin/micro-httpd /var/www/html
StandardInput=socket
[Unit]
Description=micro-httpd
Documentation=man:micro-httpd(8)
[Socket]
ListenStream=0.0.0.0:80
Accept=true
[Install]
WantedBy=sockets.target
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Access Forbidden</title>
</head>
<body>
<h1>Access Forbidden</h1>
<p>Sorry, but you do not have permission to access this page.</p>
</body>
</html>
root@host:~# chown -R www-data:www-data /var/www/html
root@host:~# systemctl restart micro-httpd.socket
Open a web browser and navigate to http://192.168.0.200/ to verify if you can see the blocked web page.
root@host:~# apt install unbound
server:
module-config: "respip validator iterator" # Load respip, validator, and iterator modules. Needed for rpz
interface: 192.168.0.200 # Network interface used for DNS queries
interface: 127.0.0.1 # Loopback interface used for local DNS queries
do-ip4: yes # Enable IPv4 support
do-ip6: no # Disable IPv6 support
do-udp: yes # Enable UDP support for DNS queries
do-tcp: yes # Enable TCP support for DNS queries
do-daemonize: yes # Run Unbound as a daemon (in the background)
access-control: 0.0.0.0/0 allow # Allow all IP addresses to make DNS queries
local-zone: "std.priv." static # Define a local zone for DNS queries
local-data: "denied.std.priv. IN A 192.168.0.200" # Define a local DNS entry for a specific domain
local-data-ptr: "192.168.0.200 denied.std.priv." # Define a local DNS PTR entry for a specific IP address
rpz:
name: rpz.std.rocks # RPZ zone name
zonefile: /etc/unbound/blacklist.zone # RPZ zone file used for DNS query filtering
*.orange.fr IN A 192.168.0.200
*.google.fr IN A 192.168.0.200
root@host:~# systemctl restart unbound
Now that we have built our web filtering system, it is time to make it useful. To do so, we will download a block list file and format it to make it work with our architecture. There are many different lists available on the internet (search for RPZ block list). Let's try one of the lists available on https://github.com/hagezi/dns-blocklists.
website.to.block CNAME .
website.to.block IN A 192.168.0.200
So, how can we translate the default format to the one useful in our architecture, as seen above? There are different approaches, but personally, I will use the sed editor. Let's see how to do that!
root@host:~# wget https://raw.githubusercontent.com/hagezi/dns-blocklists/main/rpz/multi.txt
root@host:~# sed -i 's/CNAME.*/IN A 192.168.0.200/' multi.txt
Note: To prevent users from bypassing the policy, I recommend adding a DoH/VPN/TOR/Proxy list and blocking DoH IP addresses on your firewall. (Check this list: https://github.com/crypt0rr/public-doh-servers).
One thing that can be useful is to use access control to apply different filters to the network or hosts. Let's see how it works.
server:
module-config: "respip validator iterator" # Load respip, validator, and iterator modules
interface: 192.168.0.200 # Network interface used for DNS queries
interface: 127.0.0.1 # Loopback interface used for local DNS queries
do-ip4: yes # Enable IPv4 support
do-ip6: no # Disable IPv6 support
do-udp: yes # Enable UDP support for DNS queries
do-tcp: yes # Enable TCP support for DNS queries
do-daemonize: yes # Run Unbound as a daemon (in the background)
access-control: 0.0.0.0/0 allow # Allow all IP addresses to make DNS queries
local-zone: "std.priv." static # Define a local zone for DNS queries
local-data: "denied.std.priv. IN A 192.168.0.200" # Define a local DNS entry for a specific domain
local-data-ptr: "192.168.0.200 denied.std.priv." # Define a local DNS PTR entry for a specific IP address
define-tag: "social adult dnsbypass"
access-control-tag: 192.168.10.0/24 "social adult dnsbypass"
access-control-tag: 192.168.10.200/32 "social adult"
access-control-tag: 192.168.20.0/24 "adult dnsbypass"
rpz:
name: rpz.social.std.rocks # RPZ zone name
zonefile: /var/lib/unbound/social_networks/blacklist.zone
tags: "social"
rpz:
name: rpz.adult.std.rocks # RPZ zone name
zonefile: /var/lib/unbound/adult/blacklist.zone
tags: "adult"
rpz:
name: rpz.dnsbypass.std.rocks # RPZ zone name
zonefile: /var/lib/unbound/dns_bypass/blacklist.zone
tags: "dnsbypass"
As we can see, we've created three different web filtering lists: social, adult, and dnsbypass. We have applied social adult dnsbypass to the 192.168.10.0/24 network, social adult to the 192.168.10.200 host, and adult dnsbypass to the 192.168.20.0/24 network.
Using lists with more than hundreds of thousands of entries can cause an error when starting unbound. In general, this is because the service takes too long to start and is automatically stopped by the system. Here's how to change the time allowed for the unbound service to start.
root@host:~# systemctl restart unbound
Job for unbound.service failed because a timeout was exceeded.
See "systemctl status unbound.service" and "journalctl -xeu unbound.service" for details.
root@host:~# export EDITOR=vim
root@host:~# systemctl edit unbound.service
### Editing /etc/systemd/system/unbound.service.d/override.conf
### Anything between here and the comment below will become the new contents of the file
[Service]
TimeoutStartSec=300
TimeoutStopSec=300
### Lines below this comment will be discarded
### /lib/systemd/system/unbound.service
# [Unit]
# Description=Unbound DNS server
# Documentation=man:unbound(8)
# After=network.target
# Before=nss-lookup.target
# Wants=nss-lookup.target
#
# [Service]
# Type=notify
# Restart=on-failure
# EnvironmentFile=-/etc/default/unbound
# ExecStartPre=-/usr/libexec/unbound-helper chroot_setup
# ExecStartPre=-/usr/libexec/unbound-helper root_trust_anchor_update
# ExecStart=/usr/sbin/unbound -d -p $DAEMON_OPTS
# ExecStopPost=-/usr/libexec/unbound-helper chroot_teardown
# ExecReload=+/bin/kill -HUP $MAINPID
#
# [Install]
# WantedBy=multi-user.target
root@host:~# systemctl restart unbound
Contact :