Samstag, 14. Oktober 2017

Unbound DNS mit Fritz!Box Hosts füttern (TR-064)

Der Fritz!Box DNS für lokale Clients hat sich ja zugegebenermaßen deutlich verbessert. Früher musste man noch auf Feetz einen dnsmasq laufen haben um lokale Clients auflösen zu können.

Allerdings klappen gelegentlich einge Clients trotzdem nicht, so dass die Aktion im HomeAutomation schonmal ins Leere greift.
Außerdem ist die Filterliste in der Fritzbox auf ca. 500 Einträge beschränkt, was keinen automatisierten AdBlock erlaubt - die Listen sind z.T. deutlich länger.

Schließlich gibt es mit Unbound auch einen schönen kleinen (im Vergleich zu BIND) DNS Cache/Forwarder/Resolver/mini-Server, der innovative Mechanismen wie DNSSEC und QName-Minimization mitbringt.

Dieser Beitrag beschreibt, wie man mit ein bisschen Perl die Host-Liste eienr Fritz!Box abfragt und für Unbound speichert.

Zunächst benötigt man (zumindest offiziell) einen Benutzer für den TR-064 Zugang zur Fritz!Box:
Fritzbox Benutzer verwalten
Fritzbox Benutzer Berechtigungen
Fritzbox Anmelde Berechtigung

(AVM scheint in jeder Firmware Release etwas an den Berechtigungen zu schrauben, in frührenen Versionen konnte man auch einen unpriviligierten Zugang für TR-064 hinzufügen.)

Um die korrekte SOAP Syntax der Nachrichten nicht vollständig selbst tippen zu müssen, benutze ich das Modul SOAP::Lite (Debian: libsoap-lite-perl).

Die Herausforderung bestand im Wesentlichen darin, den Antwort-Hash auf die GenericHostEntry Anfrage in eine Variable zu bekommen. Das erledigt die Methode:
... valueof('//GetGenericHostEntryResponse')

Um bei einem Reload nicht ständig den Cache von Unbound zu leeren, werden die neu erzeugten Dateien jeweils noch mit den aktiven verglichen und nur bei Änderung übernommen.

#!/usr/bin/perl -w

use strict;
use File::Copy;
use File::Compare;
use SOAP::Lite; # +trace => 'debug', readable => 1;

my $fritzboxIP = '192.168.123.1';  # falls umbenannt IP eintragen
my $fritzboxPort = '49000';        # nicht ssl-Port
my $login = 'smarthome';           # ggf. eintragen
my $password = 'geheim';           # ggf. eintragen

my $localdomain = 'fritz.box';
my $localdns = 'raspi';            # Unbound host
my $unboundconfincludedir = '/etc/unbound/unbound.conf.d';
my $unboundpidfile = '/var/run/unbound.pid';
###

my $confchanged = 0;
my $tmp = '/tmp';

#
# initial SOAP request
#
my $client = SOAP::Lite
                -> uri( 'urn:dslforum-org:service:Hosts:1' )
                -> proxy( 'http://'.$fritzboxIP.':'.$fritzboxPort.'/upnp/control/hosts' );

eval {
        my $numberOfHosts = $client->GetHostNumberOfEntries()->result;

        #
        # get data for each host index
        #
        my %IPList;
        if( $numberOfHosts > 0 )
        {
                for( my $i=0; $i<$numberOfHosts; $i++ )
                {
                        my $host = $client->GetGenericHostEntry(SOAP::Data->type('integer')->name('NewIndex')->value($i))->valueof('//GetGenericHostEntryResponse');
                        if( $host->{'NewIPAddress'} && $host->{'NewHostName'} )
                        {
                                $IPList{$host->{'NewIPAddress'}} = $host->{'NewHostName'};
                        }
                }
        }

        #
        # the forward lookup file
        #
        open UNBOUND, '>', $tmp .'/'. $localdomain .'.conf';
        print UNBOUND 'server:
        unblock-lan-zones: yes
        insecure-lan-zones: yes
        local-zone: "'. $localdomain .'." static
                # zone metadata
                local-data: "'. $localdomain .'. 3600 IN SOA '. $localdns .'.'. $localdomain  .'. hostmaster 1 2h 15m 500h 1h"
                local-data: "'. $localdomain .'. 3600 IN NS '. $localdns .'.'. $localdomain  .'."
                local-data: "'. $localdomain .'. 3600 IN A '. $fritzboxIP .'"
';

        foreach my $IP ( sort( keys %IPList ) )
        {
                printf( UNBOUND "\t\tlocal-data: \"%-30s 3600 IN A %s\"\n", $IPList{$IP} .'.'. $localdomain .'.', $IP );
        }
        close UNBOUND;

        if( compare( $tmp .'/'. $localdomain .'.conf', $unboundconfincludedir .'/'. $localdomain .'.conf' ) )
        {
                move( $tmp .'/'. $localdomain .'.conf', $unboundconfincludedir .'/'. $localdomain .'.conf' );
                $confchanged++;
        }else{ unlink( $tmp .'/'. $localdomain .'.conf' ); }


        #
        # the reverse lookup PTR file
        #
        my $arpa = join( '.', reverse( split( /\./, $fritzboxIP ) ) );
        $arpa =~ s/^[^\.]+\.//g;

        open UNBOUND, '>', $tmp .'/'. $arpa .'.in-addr.arpa.conf';
        print UNBOUND 'server:
                local-zone: "'. $arpa .'.in-addr.arpa." static
                local-data-ptr: "'. $fritzboxIP .' 3600 '. $localdomain .'."
';

        foreach my $IP ( sort( keys %IPList ) )
        {
                printf( UNBOUND "\t\tlocal-data-ptr: \"%-15s 3600 %s\"\n", $IP, $IPList{$IP} .'.'. $localdomain .'.' );
        }
        close UNBOUND;

        if( compare( $tmp .'/'. $arpa .'.in-addr.arpa.conf', $unboundconfincludedir .'/'. $arpa .'.in-addr.arpa.conf' ) )
        {
                move( $tmp .'/'. $arpa .'.in-addr.arpa.conf', $unboundconfincludedir .'/'. $arpa .'.in-addr.arpa.conf');
                $confchanged++;
        }else{ unlink( $tmp .'/'. $arpa .'.in-addr.arpa.conf' ); }


        #
        # reload unbound if config has changed
        #
        if( -f $unboundpidfile && $confchanged )
        {
                open( PID, '<', $unboundpidfile );
                chomp(my $pid=<PID>);
                close(PID);
                kill 'HUP', $pid;
        }

        exit 0;
} or do {
        if( $@ ){ die( "Error: $@\n" ); }
};

# in case authentication is needed
sub SOAP::Transport::HTTP::Client::get_basic_credentials {
        return $login => $password;
}