Haskell ist derartig faul, dass die gängigen Methoden zur Abschätzung der Programmlaufzeit scheitern. Nach der bahnbrechenden Arbeit von Okasaki bestand aber Hoffnung, dieses Thema in den Griff zu bekommen. Einige der Annahmen dieser Arbeit sind nicht anwendbar, wenn Haskell selbst zum Aufräumen von Zwischenergebnissen zu faul ist. Um selbst einige Ideen auszuprobieren, brauche ich reale Messwerte. Und die will ich jetzt erzeugen.

Profiling

Der klassische Weg mit Performance Problemen umzugehen, ist Profiling. Als Beispiel muss wieder die Fibonacci-Folge herhalten. Und das besonders ineffizient:

fib :: Int -> Int
fib 0 = 1
fib 1 = 1
fib n = a + b
 where
  a = fib (n-1)
  b = fib (n-2)

Ich habe die Zwischen-Terme a und b extra benannt, damit sie im Profiling dann auch auftauchen.

Ich möchte die Zeit messen, die Haskell zur Abarbeitung braucht, dazu lasse ich mir die verstrichene CPU Zeit vor und nach der Abarbeitung geben. Um das Programm mit verschiedenen Werten zu testen, lese ich das Argument der Funktion von der Kommando-Zeile ein.

import System.Environment
import System.CPUTime

main = do
  [arg] <- getArgs
  limit <- readIO arg
  print limit
  start <- getCPUTime
  let result = fib limit
  finish <- getCPUTime
  print (finish - start) -- picoseconds

Nun noch kompilieren und probieren:

$ ghc -O3 -prof -fprof-auto -rtsopts fib.hs
[1 of 1] Compiling Main             ( fib.hs, fib.o )
Linking fib ...
$ fib 20
20
14000000
$ fib 200
200
14000000

Huch? Warum sollte das Programm die gleiche Zeit brauchen, wenn es doch so unterschiedliche Berechnungen zu vollführen hat? Die angegebene Zeit ist in Picosekunden, also stehen da real 14µs. Etwas wenig.

Schauen wir mal in das Profiling:

$ fib 200 +RTS -p
200
5000000
$ cat fib.prof
                                                  individual      inherited
COST CENTRE MODULE             no.     entries  %time %alloc   %time %alloc

MAIN        MAIN                54          0    0.0    1.1     0.0  100.0
 CAF        Main               107          0    0.0    0.1     0.0    2.2
  main      Main               108          1    0.0    0.0     0.0    0.0
 CAF        GHC.IO.Encoding     95          0    0.0    5.5     0.0    5.5
 CAF        GHC.IO.Handle.FD    92          0    0.0   56.5     0.0   56.5
 CAF        GHC.IO.Handle.Text  91          0    0.0    0.1     0.0    0.1
 CAF        Text.Read.Lex       88          0    0.0    1.1     0.0    1.1
 CAF        GHC.IO.Encoding.Ic  83          0    0.0    0.4     0.0    0.4
 CAF        GHC.Conc.Signal     73          0    0.0    1.0     0.0    1.0
 CAF        GHC.Read            68          0    0.0    1.5     0.0    1.5
 main       Main               109          0    0.0   30.5     0.0   30.5

Man sieht sehr schön. dass die übergebene Zahl vom Lexer bearbeitet, Daten ausgegeben wurden. Was aber komplett fehlt, ist jedoch die Abarbeitung der fib-Funktion?

Haskell ist schlicht zu faul, die Berechnung überhaupt auszuführen, wenn sowieso niemand das Ergebnis braucht! Um dem abzuhelfen, muss man das Ergebnis also auch noch ausgeben.

main = do
  [arg] <- getArgs
  limit <- readIO arg
  print limit
  start <- getCPUTime
  let result = fib limit
  finish <- getCPUTime
  print (finish - start)
  print result

Einfach am Ende noch das Ergebnis ausgeben. Sollte tun, oder?

$ fib 35
35
9000000
[ ... Pause ...]
14930352

Das Ergebnis erscheint mit deutlicher Verzögerung nach der Ausgabe. Insofern sind  die angegebenen 9µs definitiv unpassend. Was ist passiert?

Haskell war zu faul, das Ergebnis sofort zu berechnen. Erst als es durch die Ausgabe gezwungen wurde, hat es die Berechnung nachgeholt. Da war die Zeitmessung allerdings schon lange vorbei.

Die Verwendung des Ergebnisses muss also vor dem Ende der Zeitmessung erfolgen!

main = do
  [arg] <- getArgs
  limit <- readIO arg
  print limit
  start <- getCPUTime
  let result = fib limit
  print result
  finish <- getCPUTime
  print (finish - start)

Und das gibt:

$ fib 35 +RTS -p
35
14930352
1354134000000

Jetzt zeigt die Zeitmessung auch passende 1,3 Sekunden. Und was sagt das  Profilinging?

                                                  individual      inherited
COST CENTRE   MODULE                   entries  %time %alloc   %time %alloc

MAIN          MAIN                          0    0.0    1.1   100.0  100.0
 CAF          Main                          0    0.0    0.0     0.0    2.1
  main        Main                          1    0.0    2.0     0.0    2.0
 CAF          GHC.IO.Encoding               0    0.0    5.4     0.0    5.4
 CAF          GHC.IO.Handle.FD              0    0.0   54.6     0.0   54.6
 CAF          GHC.IO.Handle.Text            0    0.0    0.1     0.0    0.1
 CAF          Text.Read.Lex                 0    0.0    1.1     0.0    1.1
 CAF          GHC.IO.Encoding.Iconv         0    0.1    0.4     0.1    0.4
 CAF          GHC.Conc.Signal               0    0.0    1.0     0.0    1.0
 CAF          GHC.Read                      0    0.0    1.5     0.0    1.5
 main         Main                          0    0.0   32.8    99.9   32.8
  main.result Main                          1    0.0    0.0    99.9    0.0
   fib        Main                   29860703   28.3    0.0    99.9    0.0
    fib.a     Main                   14930351   40.8    0.0    40.8    0.0
    fib.b     Main                   14930351   30.8    0.0    30.8    0.0

Die eine Zahl Differenz zwischen a und b macht allein 10% der benötigten Rechenleistung aus. Das ist beachtlich.

Zeitverhalten

Zurück zum urspünglichen Problem: Wie schnell sind Algorithmen in Haskell? Die Frage bezieht sich normalerweise auf die Entwicklung des Zeitverhaltens in Abhängigkeit von der zu verarbeitenden Datenmenge.

Grundsätzlich könnte man das bisherige Programm für jeden Zahlenwert einzeln aufrufen und die Zeiten aufschreiben. Aber das ist aufwändig und kann von Haskell selbst gemacht werden.

Zunächst einmal ist zuverlässig sicher zu stellen, dass der Versuch der Berechnung nicht über alle Zeiten hinauswächst. Die Berechnung muss notfalls abgebrochen werden. Das löst die Funktion System.Timeout.timeout: Sie lässt die Berechnung in parallelen Thread laufen und bricht den notfalls ab. Das ganze Signalisierungsverhalten ist zwar prinzipell einfach, aber die Bibliotheksfunktion löst auch die kleinen Dreckeffekte, über die man selbst noch nicht gestolpert ist.

Anderseits ist der bisherige Rückgabewerte von CPUTime schwer verständlich. Besser wäre ein klarer Wert ins Sekunden. Deswegen wechsle ich hier auf Data.Time.getCurrentTime.

Der dritte Punkt, der zu beachten ist, behandelt die vollständige Evaluierung der Eingabedaten vor der Ausführung der zu testenden Funktion, sowie das Erzwingen der notwendigen Berechnung. Dazu verlange ich zwei Hilfsfunktionen: pre dient dazu, aus dem numerischen Wert der Datenmenge auch brauchbare Ausgangsdaten zu erzeugen, z.B. eine Baumstruktur, über die der Algorithmus dann laufen soll. post dagegen extrahiert aus dem Ergebnis des Algorithmus eine Zahl. Diese Extraktion muss nicht zwingend alle Teile des Ergebnisses auswerten, es genügt z.B. die Länge eines Rückgabestrings zu ermitteln, wenn es sich um einen Algorithmus zur Stringverarbeitung handelt, schließlich sind die konkreten Inhalte des Strings dabei nicht relevant.

Die Eingabedaten werden komplett angezeigt, um alle Details zu evaluieren. Zum Erzwingen der Auswertung an der richtigen Stelle werden die Zahlenwerte auf positive Wertebereiche per Control.Monad.guard geprüft.

import Data.Time
import Control.Monad

profile :: (Show a) => (a -> b) -> (Int -> a) -> (b -> Int) -> Int
        -> IO (Maybe NominalDiffTime)
profile test pre post n = do
  let input = pre n
  guard $ 0 <= length (show input) -- fully evaluate before measuring
  timeout 5000000 (do
      start <- getCurrentTime
      let output = test input
      guard $ 0 <= post output -- evaluate the relevant part
      finish <- getCurrentTime
      return $ finish `diffUTCTime` start
    )

Nochmal in Worten:

  • Die Funktion pre macht aus einem Zahlenwert die Datenstruktur a, auf der der Algorithmus arbeiten soll.
  • Die Funktoin test führt den Algorithmus aus, in dem sie als Argument eine Datenstruktur a übergeben bekommt und eine Datenstruktur b zurück liefert.
  • Die Funktion post presst aus der Ergebnisstruktur b einen positiven Zahlenwert heraus, wobei sie die Ausführung des Algorithmus erzwingt.
  • Klappt alles, erhalten wir die Zeit, die die Funktionen test und post benötigt haben.
  • Die Berechnung ist mit einem Timeout von 5 Sekunden umschlossen: Endet die Berechnung nicht rechtzeitig gibt es Nothing.

Mit einer solche Mess-Funktion kann man nun eine Mess-Reihe automatisch erzeugen:

mkProfile :: (Show a) => (a -> b) -> (Int -> a) -> (b -> Int) -> IO ()
mkProfile test pre post = worker (profile test pre post) testpoints
 where
  testpoints = [ 1 .. 9 ]
            ++ [ x*10^c | c <- [0 .. ], x <- [10 .. 99] ]
  worker f (x:xs) = do
    r <- f x
    case r of
      (Just y) -> do putStrLn . shows x . showChar ',' . init $ show y
                     when (y < 5) $ worker f xs
      _        -> putStrLn "# Timed out"

Für eine Reihe von Testpunkten, die immer größere Abstände annehmen, wird die Laufzeit ermittelt. Da die Darstellung der Laufzeit ein finales "s" für Sekunden am Ende hat, wird es per init abgeschnitten. Der Rest der Ausgabe ist ein CSV.

Die Messung wird fortgeführt, wenn überhaupt ein Ergebnis ermittelt werden konnte (kein Timeout) und die Laufzeit die 5 Sekunden nicht überschreitet. Die Timeout-Funktion garantiert nur, dass der Abbruch nicht vor 5 Sekunden stattfindet. Länger kann es durchaus dauern, da die typische Haskell-Runtime kooperatives Multitasking betreibt.

Und was sagt das zu unserer Fibonacci-Folge?

main = mkProfile fib id id

Die Hauptfunktion ist denkbar einfach: Erstelle ein Profil der fib-Funktion, wobei die Eingabemenge aus der Zahl besteht, die übergeben wird. Das Ergebnis ist ebenfalls eine Zahl, die so übernommen wird wie sie ist.

image005

Das ist klar expotentielles Wachstum.

Damit steht nun ein Handwerkszeug zur Verfügung, um andere - interessantere Algorithmen - zu untersuchen.

Nachbetrachtung

Natürlich würde man die Fibonacci-Folge in Haskell niemals so schreiben, sondern eher so:

fibs = 1 : 1 : zipWith (+) fibs (tail fibs)
fib1 x = fibs !! x

main = mkProfile fib1 id abs

Die Fibonacci-Folge besteht aus den Werten 1, 1 und dann der Summe der Fibonacci-Werte beginnend am Anfang und um ein Element versetzt. Die Liste ist unendlich lang, aber dank des faulen Haskells besteht da kein Problem: Denn nur der x-te Wert wird dann heraus genommen.

Da wir mit Int rechnen, gibt es Überläufe während der Rechnung. Anstatt auf den korrekten Typ Integer auszuweichen, nehme ich hier einfach den Absolutwert als Ergebnis. Es geht ja nicht um die Berechnung der Werte an sich, sondern um die Art der Ermittlung (also den Algorithmus).

Und das ergibt dann?

image007

Das ist spannend! Mit einigen Ausreißern geht die Berechnung fast linear durch und zwar in die zig Millionen, statt bis 42.

Beobachtet man allerdings den RAM-Verbrauch während der Abarbeitung, so steigt dieser bis zu 4 GB an (mehr gebe ich dem Prozess hier nicht). Dann bricht die Abarbeitung ab. Die Stellen, bei denen die Laufzeit so stark ansteigt, entspricht den Stellen, wo das System massiv nach RAM verlangt.

Was ist passiert?

Die Definition von fibs ist global, d.h. der Wert steht immer zur Verfügung. Anders gesagt, verwirft Haskell die ermittelten Werte des Feldes nie wieder. Deswegen steigt der RAM-Verbrauch immer weiter an. Am Ende sind über 40 Mio. Werte berechnet worden. Incl. Overhead für die verkettete Liste sind das also ungefähr 80 Byte pro Eintrag.

Andererseits führt das zu den niedrigen Laufzeiten, da ja alle Werte schon berechnet wurden.

Alle Werte? Nein, die Schrittweite steigt ja immer um eine Größenordung. Damit müssen auch immer mehr Zwischenwerte berechnet werden, um auf das nächste Ergebnis zu kommen. Dies erklärt die sprunghaft steigende Abarbeitungszeit, die mit der größeren Schrittweite korreliert.

Was wäre nun, wenn man korrekterweise nicht mit den übrig gebliebenen Berechnungen des vorherigen Versuchen zu betrügen versucht?

fib2 x = fib' !! x
  where fib' = 1 : 1 : zipWith (+) fib' (tail fib')

Hier werden die Zwischenergebnisse in einer lokalen Variablen gehalten, die außerhalb des Aufrufs keine Bedeutung hat. Es besteht sogar die Hoffnung, dass die nicht benötigten Zwischenergebnisse direkt verworfen werden, und so der RAM-Bedarf nicht exorbitant steigt.

image007

Zwei Dinge sind auffällig:

  • Der RAM-Bedarf steigt wieder so an, das Programm hält aber länger durch, wenn die Grenze erreicht ist.
  • Das Laufzeitverhalten ist ähnlich, erreicht aber deutlich höhere Werte.

Was ist passiert?

Haskell erkennt durchaus, dass die Definition von fib' unabhängig vom Aufrufparameter ist und benutzt diese Tatsache zu dem Memorization-Trick, der bei fibs noch explizit aufgeschrieben wurde. Das führt zu dem ähnlichen Verhalten bis zu 40 Mio. Danach muss (und kann) Haskell die Zwischenergebnisse immer wieder verwerfen, um weiter arbeiten zu können.

Kann man Haskell den Algorithmus expliziter vermitteln? Schließlich ist es ja wenig elegant, eine Liste von uninteressanten Werten jedesmal von Anfang an zu durchlaufen (was schonmal minimal lineare Laufzeit generiert)

fib3 x = head $ drop x fib'
 where fib' = 1 : 1 : zipWith (+) fib' (tail fib') 

Der einzige Unterschied ist hier, dass statt dem (!!) Operator, der eine Liste bis zum x-ten Element durchläuft, der Anfang der Liste weggeworfen wird, um dann das erste Element zu nehmen.

image007

Das gleiche Bild, allerdings sind die Laufzeiten kürzer und die Abarbeitung erfolgt glatter. Es ist nicht so erratisch. Das exorbitante RAM-Bedarf ist allerdings noh da.

Also muss auf die Liste als solche verzichtet werden.

fib4 x = fib' x (1,1)
 where
  fib' 0 (a,_) = a
  fib' n (a,b) = fib' (n-1) (b,a+b) 

Dieses Programm nimmt den Kern der zipWith (+) Funktion explizit her und zählt parallel rückwärts. Es sammelt die Werte gar nicht erst auf, sondern verwirft sie sofort.

image007

Der RAM-Bedarf ist diesmal konstant. Das ist allerdings erwartungskonform.

Eine echte Überraschung ist allerdings die Laufzeit dieses Algorithmus:

  • Die Laufzeit ist praktisch linear mit 2 Sekunden je 10 Mio.
  • Allerdings sind die anderen Algorithmen durch die Bank deutlich schneller als die direkte Berechnung der Werte.
Laufzeit im Vergleich
Mio fib1 fib2 fib3 fib4
10 53ms 53ms 52ms 1750ms
20 192ms 189ms 189ms 3714ms
30 349ms 264ms 238ms  
40 321ms 361ms 298ms  
50   839ms 339ms  
60   366ms 365ms  

Fazit

Die von Haskell angewendeten Methoden der Memorization gestatten es, perfomante Algorithmen zu schreiben, die im Gegensatz zu den klassischen Algorithmen geschickter betrügen. Man muss bei Performance Messungen also höllisch aufpassen.

Wir haben eine Reihe von ASAs im Einsatz, die auf Kundenwunsch gepflegt werden. Wie immer betreffen Änderungswünsche nur die Einrichtung von Freischaltungen, aber nie das Wegräumen von Altlasten. Für diesen Fall gibt es zwei Tools, die ich vorstellen möchte.

Information des Kunden

Zunächst ist es wichtig, den Kunden regelmäßig über die aktuelle Konfiguration zu informieren. Trivialerweise würde man ihm die Konfiguration zugänglich machen.

Aber das ist eine schlechte Idee, da die ASAs einiges an Konfiguration enthalten, die man nicht ständig durch die Gegend reichen möchte. Es gibt auch genug Fälle, wo eine ASA mehrere Kunden bedient (z.B. mehrere Hosting-VLANs). Dann darf natürlich keine Information über die Konfiguration für andere Kunden leaken.

Es ist also notwendig, sich auf die wesentlichen und interessanten Teile zu beschränken. In diesem Fall interessiert den Kunden, welche ACLs und welche NAT Regeln für ihn aktiv sind. Was den Kunden interessiert, hängt von den Interfaces ab, die ihm zugeordnet sind.

Das Script mkacl-from-asaconfig.pl liest auf Standardeingabe eine komplette abgespeicherte ASA-Konfig ein, erwartet als Argument die Liste der interessanten Interfaces (nameif) und gibt aus:

  • die den Interfaces zugeordneten access-groupKommandos
  • die den access-group zugeordneten access-list Einträge
  • die den Interfaces zugeordneten nat Kommandos
  • die dabei referenzierten object-group Definitionen
  • die dabei referenzierten object Definitionen

Ganz praktisch sieht das dann so aus:

$ zcat /archiv/cf.asa.20180111.gz | mkacl-from-asaconfig.pl iks-lab
: Written by enable_15 at 11:13:55.766 CET Thu Jan 11 2018
!
!!!!!!
! active ACLs at interfaces
!!!!!
access-group to_lab out interface iks-lab
!!!!!
! ACLs
!!!!!
access-list to_lab extended permit ip object-group iks any 
access-list to_lab extended permit tcp any object obj-lab1 object-group http_s 
access-list to_lab extended permit tcp any object obj-lab2 object-group http_s 
!
!!!!!!
! extra NATs
!!!!!
!!!!!
! Object groups
!!!!!
object-group network iks
 network-object object o4-iks
 network-object object o6-iks
!
object-group service http_s tcp
 port-object eq www
 port-object eq https
!
!!!!!
! Objects
!!!!!
object network obj-lab1
 host 192.168.91.10
 nat (iks-lab,outside) static o4-lab1-public
!
object network obj-lab2
 host 192.168.91.11
 nat (iks-lab,outside) static o4-lab2-public
!
...
!
!!!!!
! Generated at Thu, 11 Jan 2018 13:35:03 +0100

Diese Auszüge können dem Kunden auf definiertem Wege (z.B. Kundenportal) zur Verfügung gestellt werden.

Es gestattet dem Kunden, sich selbst die aktuelle Konfiguration anzusehen und entlastet die Hotline bei uns.

Inkonsistenzen

Ein ganz anderer Teil des Problem sind Inkonsistenzen und Altlasten. Die Definition eines Objektes, das nicht verwendet wird, ist eine Inkonsistenz. Die Verwendung einer IP Adresse, die nicht mehr in Benutzung ist, ist eine Altlast. Beides soll gefunden werden.

Wie eine Konfiguration auszusehen hat, ist selbstverständlich Ansichtssache. Bei uns hat es sich als günstig herausgestellt, sämtliche IP Adressen in Objekten und Objektgruppen zu sammeln, damit diese schon allein anhand ihrer Benennung oder description auch noch Jahre später zugeordnet werden können. Nur mit klarer Zuordnung ist ein späterer Rückbau überhaupt möglich.

Das Script clean-asa-config.pl liest von der Standardeingabe eine komplette Konfigurationsdatei und listet auf, was unstimmig erscheint:

  • Nicht mehr benutzte access-list, object, object-group. name, tunnel-group, class-map, transform-set, crypto map, ...
  • Elemente, die verwendet werden, aber nicht definiert wurden, werden aufgelistet.
  • Es wird das Anlegen von object oder name Definitionen empfohlen, wenn IPs direkt in der Konfiguration stehen.
  • Ist bei Objekten ein DNS-Name zur IP hinterlegt, so wird geprüft, ob der Name noch zu dieser IP auflöst. Es ist nicht empfehlenswert, direkt mit fqdn statt host zu arbeiten, wenn man feststellen will, ob die intendierte Eintragung noch korrekt ist. fqdn sollte man nur bei dynamischen IPs benutzen.
  • Bei name Definitionen wird geprüft, oben IP und Name noch per DNS zusammen passen.
  • Da name Definitionen agnostisch gegenüber der Protokollfamilie sind, wird für IPv4 ein trailing dot am Namen empfohlen, bei IPv6 nicht.
  • Ausgelaufene Zertifikate führen zur Empfehlung, den trust-point zu löschen.
  • Ebenso soll man unvollständige crypto map Einträge löschen.

Die Ausgabe enthält:

  • die Empfehlung incl, Begründung
  • die direkt ausführbaren Kommandos, um die Empfehlung umzusetzen, ohne den Betrieb zu gefährden
  • alle anderen Verwendungen des Namens oder der IP in der Konfiguration

Wenn Euch etwas auffällt, was man noch testen sollte oder könnte ...

VPNs dienen der geschützten Kommunikation, entsprechend einfach sollte ihre Einrichtung sein. Unglücklicherweise ist aber schon die Festlegung, was genau schützenswert ist, Ergebnis einer komplexen Abstimmung. Deswegen werden zunehmend VPNs global aufgemacht und nur bei Bedarf genutzt. Ein striktes Gerät, wie die ASA, mag das gar nicht. Ein Endkundengerät lieb es …

IPSec klassisch

Der klassische Ansatz ist es, für jedes Paar von geschützten Netzen einen Tunnel anzulegen, der je nach Bedarf aufgebaut und abgebaut werden kann. Diese Tunnel nennt man Security Association, sie definieren sich über die IP-Adressen der beteiligten Netze.

vpn-policy

Durch die dynamischen Tunnel muss jedes beteiligte Gerät wissen, welche Paare von Netzen zu schützen sind und notfalls einen Tunnelaufbau initiieren. Es ist unzulässig, Pakete unverschlüsselt zu übertragen, wenn die Policy Verschlüsselung vorschreibt.

Man kann eine solche Policy also auch so verstehen, das es eine ACL gibt, die festlegt, welcher Datenverkehr verschlüsselt ist oder nicht. Konsequenterweise enthalten die Tunnel dann auch Protokoll und Portnummern, wenn nicht rein auf IP Ebene selektiert wird.

Umgekehrt wird die Policy-ACL auch bei eingehendem Verkehr angewendet. Was laut Policy verschlüsselt zu sein hat, muss verschlüsselt ankommen. Pakete, die am VPN vorbei eintrudeln, werden kommentarlos verworfen.

Das offenkundige Problem mit dem Ansatz ist der Koordinierungsaufwand:

  • Beide Seiten müssen sich über die exakten Policy-ACLs klar sein.
  • Änderungen sind nur beidseitig und gleichzeitig möglich.

IPSec simplified

Die offenkundige Verbesserung besteht darin, dem Tunnel nicht mehr mit den IP-Adressen zu verknüpfen. Stattdessen wird ein generischer Tunnel aufgebaut, der beliebige Datenpakete verschlüsselt transportieren kann.

Jede Seite legt für sich fest, welche Ziele durch den Tunnel und welche unverschlüsselt übertragen werden sollen.

vpn-routing

Da man die Entwicklung eines neuen Protokolls scheute, wird ein klassischer IPSec Tunnel genutzt, der jedoch maximal breit arbeitet: Beliebige Quell- und Ziel-Adressen passen in den Tunnel.

Selbstverständlich kann man nun nicht mehr mit (Policy-)ACLs arbeiten, denn dann müsste sämtlicher Datenverkehr zwangsweise in den Tunnel geschoben werden. Stattdessen verzichtet man komplett auf die ACLs und routet die Ziel-Adressen entweder in den Tunnel oder ins Internet.

Auf diese Weise können Tunnel nach Bedarf auf jeder Seite einzeln konfiguriert werden: Es gibt einen existenten Tunnel und da wirft man rein, was wichtig scheint. Die Gegenseite muss nicht mehr informiert werden.

Eine Konsequenz aus dem Verzicht auf die ACLs, ja auf den Verzicht auf Abstimmung, ist, dass das empfangende Gerät nicht mehr feststellen kann, ob das ankommende Paket verschlüsselt hätte sein sollen oder nicht. Bei Fehlkonfigurationen kann ein Teil des Traffics unverschlüsselt laufen.

vpn-routing-fail

Die Vorteile liegen auf der Hand:

  • Einfaches Setup (nur Gegenstelle und Schlüssel notwendig)
  • Verschlüsselung nach Bedarf (des Senders) beliebig erweiterbar
  • Bei Fehlkonfiguration bleibt die Funktion bestehen, nur die Sicherheit ist weg.

Mischbetrieb

Der vereinfachte Ansatz ist gerade im Endkundenbereich beliebt, weil sich der Kunde so schnell seine Lan-Kopplung zusammen klicken kann. Der striktere Ansatz ist eher im Firmenumfeld zu finden, wo man traditionell nur mit abgestimmten Konfigurationen zu tun hat.

In meinem Fall habe ich nun ein Endkundengerät (CPE), das ausschließlich nach der vereinfachten Methode arbeitet, und eine ASA, die ausschließlich nach der traditionellen Methode konfiguriert werden will.

vpn-mixed-fail

Aufgrund der Policy-ACL würde eine ASA keinen unverschlüsselten Traffic annehmen, wenn der durch den Tunnel kommen müsste. Das will ich nicht aufgeben.

An der CPE sind die Wechselmöglichkeiten zu beschränkt, um überhaupt einen anderen Typ von Verbindung hinzubekommen. Also konzentriere ich mich auf das eigentliche Problem: Den VPN Tunnel und die dazu passende Policy-ACL.

vpn-mixed

Da die CPE den Tunnel vorgibt, muss er offensichtlich ein 0.0.0.0/0.0.0.0 Tunnel werden. Das führt zur sofortigen Funktionsunfähigkeit der ASA.

Schaut man genauer auf die ACLs, so stellt man fest, dass diese mehrfach benutzt werden:

  • Jedes Paket, dass von der ACL erlaubt wird, kommt in den Tunnel oder muss daraus kommen.
  • Jedes Paket, dass von der ACL abgelehnt wird, darf nicht in den Tunnel oder daraus kommen.
  • Die Tunnel-Konfiguration ergibt sich aus den Permit-Regeln der ACL.

Der Trick besteht also darin, allen Traffic abzulehnen, der nicht in den Tunnel soll, um anschließend alles zuzulassen.

access-list vpn deny ip object-group og-not-local any 
access-list vpn deny ip any object-group og-not-remote
access-list vpn permit ip any any 

Die Objektgruppen, die den Kehrwert der eigentlich zulässigen Netze bilden, sind am einfachsten aus zwei Bereichen zu bauen, die bis an das gewünschte Netz ran gehen.

object-group network og-not-local
 network-object object o-not-local-higher
 network-object object o-not-local-lower

object network o-not-local-lower
 range 0.0.0.0 198.51.99.255
object network o-not-local-higher
 range 198.51.101.0 255.255.255.255

In gleicher Weise werden die inversen Bereiche für das Remote-Netz definiert.

Es kann nun noch vorkommen, dass die ASA sich zickig hat beim annehmen des Verbindungsaufbaus. Dann genügt es die Basisadressen der Netze zu erlauben.

access-list vpn line 1 perm ip host 0.0.0.0 host 0.0.0.0

Tut.

Eine Appliance, die in einer fremden Umgebung Daten einsammelt, soll von extern angesprochen werden. Zum einen muss der Hersteller Fernwartung machen und zum anderen gibt es eine Gruppe von Stellen, die auf die gesammelten Daten zugreifen sollen. Völlig unerwartet wird das geschützte Netzwerk dieser Stellen ebenfalls mit öffentlichen IP Adressen betrieben, so dass für beide Zwecke eine Default Route benötigt wird. Das Ganze soll trotzdem nur mit einer ASA funktionieren. Eine Aufsplittung in mehrere Kontexte (ASA-Virtualisierung) scheitert am fehlenden Support für Remote-Access. Es muss also anders gehen.

Aufgabenstellung

Die Appliance arbeitet in einem anderen, größeren Netzwerk und benutzt selbst nur private IP Adressen (hier 192.0.2.x). Um alle Messstellen erreichen zu können, zeigt die Default Route dieses Gerätes in das fremde Netzwerk (hier grau).

Der Remote-Access Teil (hier grün) ist ein einfaches Setup. Um die vorab unbekannten Clients erreichen zu können, muss die Default Route ins Internet zeigen. Dieser Uplink der ASA benutzt öffentliche IPs (PA-space, hier 189.51.100.x). Die Remote-Access Clients bekommen aus einem Adresspool des lokalen Netzes IPs, so dass sie ohne weiteres Routing auf die Appliance zugreifen können. Von Seiten der Appliance aus erscheint der Client im LAN.

Die ursprüngliche Annahme war, dass die abfragenden Stellen, die über ein extra gesichertes Netzwerk zugeführt werden, ebenfalls in koordinierter Weise mit nicht-öffentlichen Adressen arbeiten würden. In diesem Fall hätte eine spezifische Route zu all diesen Netzen genügt. Allerdings haben die Planer dieses Netzes den Koordinationsaufwand gescheut und verlangen stattdessen, öffentliche IP-Adressen aus dem lokalen PA-Space zu benutzen, der auch normal über Internet erreichbar ist.

ASA multiple default routes

Die Gegenstellen hinter dem geschützten Netzwerk (hier rot) sind vorab ebenso wenig bekannt, wie die Fernwartungs-Clients (grün). In dem hier skizzierten Bild, soll eine solche Gegenstelle eine ganz andere öffentliche IP Adresse haben (hier 203.0.113.133). Um solche Adressen zu erreichen, müsste eine Default Route ins rote Netz gesetzt werden.

Angefasst werden darf nur die ASA, alle anderen Geräte unterliegen fremder Verfügungsgewalt.

Remote-Access

Die Konfiguration für das Remote-Access VPN gemäß Lehrbuch:

interface Ethernet0/0
 nameif green
 security-level 0
 ip address 198.51.100.25 255.255.255.192
!
interface Ethernet0/1
 nameif gray
 security-level 100
 ip address 192.0.2.5 255.255.255.0
!
route green 0.0.0.0 0.0.0.0 198.51.100.1 1
!
ip local pool vpnippool 192.0.2.101-192.0.2.102 mask 255.255.255.0
!
crypto map vpn_outside 999 ipsec-isakmp dynamic vpn_dyn
crypto map vpn_outside interface green
...

Erwartungsgemäß tut das seinen Dienst:

  • Der entfernte Supportmitarbeiter kann per Default Route erreicht werden.
  • Der entfernte Supportmitarbeiter bekommt über das VPN eine IP aus dem inside-LAN.
  • Die Appliance redet mit ihm über das lokale Netzwerk. Extra Routen werden nicht benötigt.

Einmal hin ...

Um die Stellen, die mit öffentlichen IP Adressen aus dem geschützten Netz kommen, versorgen zu können, sind einige Überlegungen notwendig.

Zuerst fällt auf, dass der Verbindungsaufbau bei unbekannten Gegenstellen nur von extern kommen kann. Die Clients werden ausschließlich eine öffentliche IP au dem Netz 198.51.100.248/29 zugreifen können. Von dem privaten Netz hinter der ASA wissen sie nichts.

Damit die Appliance nichts von den fremden Adressen merkt, braucht es eine Doppel-NAT:

interface Ethernet0/2
 nameif red
 security-level 0
 ip address 198.51.100.254 255.255.255.248
!
object network o-server
 host 192.0.2.4
!
nat (red,green) source dynamic any interface destination static interface o-server

Eingehende Pakete über das rote Interface von beliebiger Quelle an die Interface-IP werden so genanntet, dass die Quell-IP zur grünen Interface-Adresse und die Zieladresse, die der Appliance wird. Damit sieht die Appliance nur Anfragen aus ihrem LAN.

Um zu testen, dass diese Idee auch tut, wird ein Testaufbau erstellt:

ASA multiple default routes Test

Anstelle des roten Netzwerkes, steht ein Linux-Rechner, den man passend konfiguriert:

# ip addr add 198.51.100.254/29 dev eth_rot
# ip addr show eth_rot
1: eth_rot: <BROADCAST,MULTICAST,UP,10000>
    inet 198.51.100.254/29 scope global eth_rot
       valid_lft forever preferred_lft forever 

Zusätzlich bekommt er die fremde IP Adresse. Damit er diese auch als Quelladresse benutzt, gibt es eine spezielle extra Route:

# ip addr add 203.0.113.133/32 dev lo
# ip route add 198.51.100.249/32 src 203.0.113.133 dev eth_rot

Wenn man testweise versucht eine Verbindung zur Appliance herzustellen, scheint es erst einmal zu tun.

# ssh 198.51.100.249
15:42:02  IP 203.0.113.133.35158 > 198.51.100.249.22: S 3034099253:3034099253(0)
15:42:03  IP 203.0.113.133.35158 > 198.51.100.249.22: S 3034099253:3034099253(0)
15:42:05  IP 203.0.113.133.35158 > 198.51.100.249.22: S 3034099253:3034099253(0)

Wie man sieht, geht die Anfrage mit der richtigen Absende-IP raus.

Allerdings kommt keine Antwort zurück. Aber warum?

... und zurück

Ein Blick auf die ASA zeigt, das die Datenpakete korrekt nattet und sogar Antworten bekommt.

 16: 15:42:02   192.0.2.5.35158 > 192.0.2.4.22: S 3034099253:3034099253(0) win 14600
 17: 15:42:02   192.0.2.4.22 > 192.0.2.5.35158: S 2009198346:2009198346(0) ack 3034099253 win 14480
 18: 15:42:03   192.0.2.5.35158 > 192.0.2.4.22: S 3034099253:3034099253(0) win 14600
 19: 15:42:03   192.0.2.4.22 > 192.0.2.5.35158: S 2009198346:2009198346(0) ack 3034099253 win 14480
 20: 15:42:05   192.0.2.5.35158 > 192.0.2.4.22: S 3034099253:3034099253(0) win 14600
 21: 15:42:05   192.0.2.4.22 > 192.0.2.5.35158: S 2009198346:2009198346(0) ack 3034099253 win 14480

Warum kommen die Datenpakete nicht wieder zurück? Weil die Default Route fehlt!

Glücklicherweise ist die ASA kein Router. Genauer gesagt die Routingentscheidung wird in zwei Schritten vorgenommen:

  • Steht das ausgehende Interface nicht fest, so wird die generische Routingtabelle befragt, um das Interface zu ermitteln.
  • Ist allerdings klar, auf welchem Bein ausgesendet werden muss, so wird nur der Teil der Routingtabelle betrachtet, der diesem Interface zugeordnet ist.

Eine der Konsequenzen aus diesem Ansatz erklärt die seltsame Anforderung, bei jeder statische konfigurierten Route immer das Interface mit angeben zu müssen: Um so eine schräge Logik umsetzen zu können, muss man erst mal die Angaben dafür haben.

In diesem Fall genügt es also eine weitere Default Route einzutragen, die eine höhere Metrik hat. Dies stellt sicher, dass die ASA keine zwei gleichberechtigten Routen erhält, mit denen sie nicht umgehen kann. Die Situation für die ASA bleibt also eindeutig.

(conf)# route red 0.0.0.0 0.0.0.0 198.51.100.254 2

An der Standard-Routingtabelle ändert sich damit ja nichts:

C    198.51.100.248 255.255.255.248 is directly connected, red
C    198.51.100.0 255.255.255.192 is directly connected, green
C    192.0.2.0 255.255.255.0 is directly connected, gray
S*   0.0.0.0 0.0.0.0 [1/0] via 198.51.100.1, green

Ganz anders ist nun aber die Situation für die Pakete dar, die aus dem (reverse)-NAT heraus fallen.

  • Der NAT Eintrag enthält die Quell- und Ziel-Interfaces, sowie die IP-Adressen und Ports.
  • Pakete bestehender Flows werden also nicht normal behandelt, sondern anhand des vorhandenen NAT-Eintrags.
  • Damit umgehen sie sämtliches konfiguriertes Routing, ACLs etc. insbesondere die Standard-Routingtabelle.
  • Da das Zielinterface fest steht, greift nur noch die Routingtabelle dieses Interfaces, also die niedrig priorisierte Default Route.

Und siehe an:

# ssh  198.51.100.249 
16:38:20  IP 203.0.113.133.51310 > 198.51.100.249.22: S 3979383937:3979383937(0)
16:38:20  IP 198.51.100.249.22 > 203.0.113.133.51310: S 3186400021:3186400021(0) ack 3979383938
16:38:20  IP 203.0.113.133.51310 > 198.51.100.249.22: . ack 1 win 115
16:38:20  IP 198.51.100.249.22 > 203.0.113.133.51310: P 1:22(21) ack 1 win 114
16:38:20  IP 203.0.113.133.51310 > 198.51.100.249.22: . ack 22 win 115
16:38:20  IP 203.0.113.133.51310 > 198.51.100.249.22: P 1:21(20) ack 22 win 115
16:38:20  IP 198.51.100.249.22 > 203.0.113.133.51310: . ack 21 win 114
 136: 16:38:20    192.0.2.5.51310 > 192.0.2.4.22: S 1381971035:1381971035(0) win 14600
 137: 16:38:20    arp who-has 192.0.2.5 tell 192.0.2.4 
 138: 16:38:20    arp reply 192.0.2.5 is-at e8:b7:48:fd:89:81 
 139: 16:38:20    192.0.2.4.22 > 192.0.2.5.51310: S 2009198346:2009198346(0) ack 1381971036 win 14480
 140: 16:38:20    192.0.2.5.51310 > 192.0.2.4.22: . ack 2009198347 win 115
 141: 16:38:20    192.0.2.4.22 > 192.0.2.5.51310: P 2009198347:2009198368(21) ack 1381971036 win 114
 142: 16:38:20    192.0.2.5.51310 > 192.0.2.4.22: . ack 2009198368 win 115
 143: 16:38:20    192.0.2.5.51310 > 192.0.2.4.22: P 1381971036:1381971056(20) ack 2009198368 win 115
 144: 16:38:20    192.0.2.4.22 > 192.0.2.5.51310: . ack 1381971056 win 114
 145: 16:38:20    192.0.2.5.51310 > 192.0.2.4.22: P 1381971056:1381971848(792) ack
 146: 16:38:20    192.0.2.4.22 > 192.0.2.5.51310: . ack 1381971848 win 126

Hurra!

Nachdem Google mit dem Public-DNS Dienst unter 8.8.8.8 zum Standard in allen Fragen der DNS-Problembehebung geworden ist, springen nun immer mehr Firmen auf. Aktuell treibt 9.9.9.9 die Sau durch Dorf. Dabei generiert diese Idee massive Probleme, wettbewerbsrechtlich und technisch.

Wettbewerb

Zunächst gibt es eine massive Beschränkung von leicht merkbaren IP-Adressen. Der IPv4 Adressraum läßt nur 220 Adressen der Form x.x.x.x zu.

Davon benutzen schon einige die IPs für DNS Dienste:

  • 8.8.8.8 PTR google-public-dns-a.google.com.
  • 9.9.9.9 PTR dns.quad9.net.
  • 75.75.75.75 PTR cdns01.comcast.net.
  • 79.79.79.79 PTR public-dns-a.as9105.net.
  • 99.99.99.99 PTR dnsr4.sbcglobal.net.
  • 108.108.108.108 PTR 108-108-108-108.pools.spcsdns.net.
  • 114.114.114.114 PTR public1.114dns.com.

Einige benutzen die IPs für ihre eigenen Nameserver:

  • 77.77.77.77 PTR ns3.hiweb.ir.
  • 93.93.93.93 PTR ns.ngenix.net.
  • 199.199.199.199 PTR NS1.Shane.co.
  • 208.208.208.208 PTR ns1648.ztomy.com.

Andere haben eigene Ideen:

  • 16.16.16.16 PTR ldtools.gre.hp.com.
  • 23.23.23.23 PTR select.zone.
  • 76.76.76.76 PTR lo0-rtc-svw.nco.riseb.net.
  • 145.145.145.145 PTR Multicast-RendezvousPoint.surf.net.
  • 192.192.192.192 PTR medmgmt-192.tajen.edu.tw.

Der Rest macht sich gar keine Gedanken und gibt die IPs an Kunden raus. 69 davon haben einen entsprechenden Reverse-DNS Eintrag.

Nehmen wir einfach mal an, es liese sich mit diesen Adressen ein DNS-Geschäft machen. Dann kommt die Frage auf, ob diese extrem knappe Ressource gerecht verteilt ist und zukünftige Marktteilnehmer mit ihren innovativen Idee auch die Chance auf Teilnahme bekommen können.

Die Antwort der zuständigen (nationalen) Wettbewerbsbehörde liegt auf der Hand: Natürlich nicht.

Damit bedürfen diese Adressen der Regulierung bzw. der Versteigerung wie bei Funkfrequenzen.

Eigenbedarf

Die offenkundigste Schlussfolgerung des Theaters um 9.9.9.9 ist, selbst weiter zu machen. Ich habe kurzerhand die IP 10.10.10.10 als DNS-Anycast konfiguriert und in Betrieb genommen.

$ dig AAAA lutz.donnerhacke.de @10.10.10.10 +dnssec +multiline
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 727
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 4, AUTHORITY: 3, ADDITIONAL: 1

;; ANSWER SECTION:
lutz.donnerhacke.de.    CNAME   pro.donnerhacke.de.
lutz.donnerhacke.de.    RRSIG   CNAME 5 3 57600 ...
pro.donnerhacke.de.     AAAA    2001:4bd8:1:1:209:6bff:fe49:79ea
pro.donnerhacke.de.     RRSIG   AAAA 5 3 57600 ...

;; AUTHORITY SECTION:
donnerhacke.de.         NS      avalon.iks-jena.de.
donnerhacke.de.         NS      broceliande.iks-jena.de.
donnerhacke.de.         RRSIG   NS 5 2 57600 ...

;; Query time: 11 msec
;; SERVER: 10.10.10.10#53(10.10.10.10)
;; WHEN: Fri Nov 17 21:04:34 CET 2017
;; MSG SIZE  rcvd: 672

Die Antwort kommt mit dem AD-Flag, das eine erfolgreich mit DNSSEC validierte Auskunft bekundet. Damit ist schon mal Sicherheit abgehakt.

Da 10.0.0.0/8 ein privater IP-Bereich ist, der nicht öffentlich erreicht werden kann, ist ausgeschlossen, dass der Kunde einen Nameserver irgendwo im Internet befragt. Stattdessen befragt er einen nächstgelegenen Server im Zuständigkeitsbereich seines ISP. Er hat damit die Gewährleistung, dass niemand die IP per Routingtricks im Internet entführt hat. In Konsequenz hat er damit sichergestellt ausschließlich mit seinem Vertragspartner zu kommunizieren und diesen haftbar machen zu können.

Die Verwendung eines lokalen Servers gestattet dem DNS Betreiber, auf die Weitergabe von Kundeninformationen zu verzichten. Ein angenehmer Nebeneffekt davon ist, dass alle Antworten für alle Anfragenden passen und somit effektiv gecached werden können. Zusammen mit der kurzen Laufzeit zwischen Server und Kunde kann so eine DNS Anfrage deutlich schneller beantwortet werden.

Da auf diese Weise die lokal günstig erreichbaren CDNs bevorzugt werden, wir das Surferlebnis insgesamt fluffiger. Regionale Datenquellen entlasten auch die Außenleitungen des ISP und erlauben so auch anderen Nutzern ein besseres Internet.

Was noch zu tun ist, ist der Hotline beizubringen, bei DNS Problemen nicht mehr auf die 8.8.8.8 zu verweisen, sondern stattdessen die 10.10.10.10 zu empfehlen. Das wird Zeit brauchen. Ebenso wird es dauern, bis die Techniker vor Ort nicht beim ersten Problem die DNS Einstellungen verändern, und wenn, dann auch die 10.10.10.10.

Es ist abzusehen, dass Kunden die 10er IPs auch intern verwenden. Für diese Kunden wird es dann die 100.100.100.100 geben. Ich bin ja nicht so.

Und wenn Du kein Kunde von uns bist? Dann tritt Deinen ISP, dass er auch einen solchen Service aufbaut! Es wäre Best Current Practice, diese IPs überall mit den o.g. Garantien erreichen zu können.

Das FreeBSD Handbuch für Portierungen ist eigentlich ziemlich klar. Allerdings funktionieren die Aktionen nur als root. Entwickeln mit solchen Rechnten mag ich nicht. Im IRC und den Mailinglisten heißt es, das sei halt so. Man könne ja mit pourdriere testen. Allerdings benötigt das ebenfalls root-Rechte.

Folgt man dem Handbuch, so ist das alles sehr einfach: Directory anlegen, Makefile schreiben und pkg-descr ausfüllen.

Der nächste Schritt ist das Herunterladen der Quellen. Schon da hatte ich erste Probleme, denn eigentlich wollte ich die Software nicht nochmal separat veröffentlichen. Aber die Idee des Ports ist halt, dass es um die Einbindung von Fremdsoftware geht. Und die liegt halt an anderer Stelle. Also lege ich eine extra Veröffentlichung an.

Damit der Port weiß, dass er die richtigen Dateien läd, werden Prüfsummen erstellt.

$ make makesum
=> parpd-1.1.tgz doesn't seem to exist in /usr/ports/distfiles/.
=> /usr/ports/distfiles/ is not writable by you; cannot fetch.
*** Error code 1

Logisch. Ich bin ja nicht root auf dem System. Und in das Standardverzeichnis darf ich nicht schreiben. Was nun?

In /usr/ports/Mk/bsd.port.mk steht:

# DISTDIR               - Where to search for and store copies of original sources
#                                 Default: ${PORTSDIR}/distfiles

Das kann ich ja umstellen, vielleicht geht's dann?

$ export DISTDIR=/tmp/myport
$ make makesum
=> parpd-1.1.tgz doesn't seem to exist in /tmp/myport/.
=> Attempting to fetch ftp://ftp.iks-jena.de/pub/mitarb/lutz/parpd/parpd-1.1.tgz
parpd-1.1.tgz                                 100% of   16 kB   10 MBps 00m00s
$ cat distinfo
TIMESTAMP = 1510327772
SHA256 (parpd-1.1.tgz) = 95318905767c1123eab87efa4fa664a57e5ed8f697802c6b7d5d0799ad8ea6e6
SIZE (parpd-1.1.tgz) = 17197

Na also! Geht doch.

Alle weiteren Schritte funktionieren dann als Nutzer ohne weitere Probleme.

Nunja, ein Problem gibt es doch noch. Hat man nämlich DEVELOPER=yes in dem make Optionen gesetzt, so erscheint bei jeden Aufruf von make

/!\ parpd-1.1: Makefile warnings, please consider fixing /!\

Not validating first entry in CATEGORIES due to being outside of PORTSDIR.
Please ensure this is proper when committing.

Ist ja auch klar. Natürlich bin ich als normaler Nutzer nicht im originalen PORTSDIR tätig. Die Meldung kann man getrost ignorieren.

Kaputtes xDSL ist ein nervendes Problem hier. Wir haben die Schmerzen gemildert, indem wir die Netzmasken verkleinerten. Während der letzten Monate verschärfte sish die Situation wieder, weil rigide Filter auf den DSLAM ausgerollt wurden und Geräte hinzu kamen, die nicht in der Lage waren mit den kleinen Netzmasken umzugehen. Wir hatten das Problem grundsätzlich zu lösen.

Problem

Große Netzwerke haben eine große Anzahl an sichtbaren MAC Adressen. Einige Geräte (z.B. DSLAMs) verhalten sich seltsam, wenn sie zuviele MACs zu sehen bekommen. Deshalb versuchen die Netzwerkbetreiber ihre Netze zu segmentieren, so dass keine Frames mehr von einer entfernten, kleinen Lokation zu eienr anderen gelangen können. Die Blockade von Quertraffic zuwischen Kunden heißt split-horizon. Kunden in verschiedenen Teilen des Netzwerkes können dabei nicht mehr miteinander kommunizieren.

local proxy arp1

Große Netzwerke sind schwer zu betreiben. Deswegen wird möglichst nahe am Endkunden, also am DSLAM gefiltert. Diese "Fist-Hop-Security" Filter sind nur für einfache Anwendungsfälle gedacht und neigen dazu Pakete in nicht-standard-Situationen zu verwerfen. Man kann dann diese Filter nur abschalten oder damit leben.

Meistens lesen diese Geräte die DHCP-Kommunikation mit und passen danach ihre Filter an. Nutzer mit statischen IP Adressen machen aber oft gar kein DHCP, so dass diese Filter nicht zu lernen haben. Andere Nutzer haben mehr als ein Endgerät oder ganze Netzbereiche zugeteilt bekommen. In all dieses Fällen versagen die DLSAM-Filter.

Eine weitere Sicherheitsmaßnahme besteht darin, den Broadcast-Verkehr zu unterbinden, so er nicht dazu dient, die MAC Adresse zur dem Filter bekannten IP zu ermitteln. Die Idee hinter diesem Filter ist, dass jeder, der die MAC Adresse des Endgeräts kennt, dann automatisch berechtigt ist, mit diesem zu kommunizieren. Es genügt also, die Broadcasts von Endkunden zu Endkunden zu blockieren, um effektiv nur noch erlaubte Kommunikation zu haben.

Setzt man DHCP-Sniffing, Broadcast-Filter und Split-Horizon zusammen ein, erhält man ein typisches xDSL-Netzwerk, in dem die Nutzer nur mit dem zentralen Router reden können. Sämtliche darüber hinaus gehende Kommunikation ist nicht möglich.

Einfache Lösung

Gibt es nur einen einzelnen Router im Netz, so genügt es dort local-proxy-arp anzuschalten: Jede ARP-Anfrage (eines Endgeräts) wird vom Router mit der MAC-Adresse des Router-Interfaces beantwortet. Auf diese Weise können die Kunden im Umweg über den Router miteinander reden (hair pinning).

local proxy arp3

Da der Router die MAC-Adresse des Endgeräts schon bei der ARP-Anfrage dieses Geräts nach seinem Default-Router lernt, muss der Router oft gar nicht selbst ARP-Anfragen per Broadcast stellen. Einige Routermodelle erneuern die auslaufenden Cache Einträge per Unicast-Anfrage, die von den DSLAM-Filtern durchgelassen werden. Auf diese Weise kann man ein solches Netz ohne merkbare Störungen betreiben.

Gibt es allerdings mehrere Router oder Server an zentraler Stelle,. so wird es kompliziert. Local-proxy-arp kann nun nicht mehr eingesetzt werden, da die Router sonst sich gegenseitig die ARP-Anfragen beantworteten und anschließend die Pakete im Kreis laufen.

Andererseits bekommen DHCP-Server Probleme, weil sie auf den ARP-Test "Ist die IP noch frei?" Fake-Antworten vom Router bekommen. Local-proxy-arp stört alle Arten dieser Anwendungen erheblich.

Anderer Ansatz

Um unser Netz hier trotzdem am Laufen zu halten, stellt eine neue Software (parpd) die notwendigen ARP-Antworten. In Abhängigkeit von frei konfigurierbaren Regeln antwortet der Daemon auf ARP-Anfragen mit einer passenden Antwort. Dies kann die reale MAC des Endgeräts sein oder auch die MAC-Adresse eines Routers (redirect).

Die Software lernt durch passives Zuhören die realen MAC-IP Paare. Selbstverständlich ignoriert sie dabei Antworten von anderen Instanzen, beschränkt sich also auf die originalen Quellen. parpd erneuert auslaufende ARP Cache-Einträge mit Unicast-Anfragen. Nur um die Redirect-MAC zu ermitteln, darf sie auf Broadcast zurück greifen.

Antworten können aber auch verzögert werden, d.h. die ersten Anfragen eines Gerätes nach einer bestimmten IP werden innerhalb einer gewissen Zeit ignoriert. Auf diese Weise kann ein DHCP-Server seine Überprüfungen vornehmen, bekommt aber dann doch eine Antwort, wenn er auf direkter Kommunikation besteht.

Die frei konfigurierbaren Regeln gestatten die Anpassung an komplexere Aufbauten, z.B. überlappenden Netzen.

Beispiel

Wie sieht das in der Praxis aus? So:

cache
 timeout       302     # seconds
 tablesize     3499    # expecting about 10000 entries
 refresh       3*5     # 3 retries a 5 seconds each
 delay         4*3     # respond at 4th retry in 3 seconds
end

interface em0
 timeout       1.011
 # do not respond for queries to our own infrastructure
 rule          0.0.0.0/0        198.51.100.0/29    ignore
 # delay queries from the DHCP server
 rule          198.51.100.4/32  198.51.100.0/24    delay tell
 # help the routers/servers to reach the clients
 rule          198.51.100.0/29  198.51.100.0/24    tell
 # interclient communication through hairpinning at the default gateway
 rule          198.51.100.0/24  198.51.100.0/24    198.51.100.1
 # help erroneous clients arping for everything
 rule          198.51.100.0/24  0.0.0.0/0          verbose 198.51.100.1
 # multihomed server with weak host model
 rule          192.0.2.0/24     198.51.100.0/24    tell
 # show missing entries
 rule          0.0.0.0/0        0.0.0.0/0          verbose ignore
end

Seit Wochen nervt eine bestimmte Kiste mit Bind, dass die Hints für den Startup von der Realität abweicht. Und erstaunlicherweise hat sie recht.

Das Problem

Oct  2 17:07:27 named[1117]: checkhints: b.root-servers.net/AAAA (2001:500:200::b) extra record in hints
Oct  2 17:07:39 named[1117]: checkhints: b.root-servers.net/AAAA (2001:500:84::b) missing from hints
Oct  2 17:07:39 named[1117]: checkhints: b.root-servers.net/AAAA (2001:500:200::b) extra record in hints
Oct  2 17:08:17 named[1117]: checkhints: b.root-servers.net/AAAA (2001:500:84::b) missing from hints
Oct  2 17:08:17 named[1117]: checkhints: b.root-servers.net/AAAA (2001:500:200::b) extra record in hints

Ich kann den Cache löschen, aber das Problem kommt einige Tage später wieder.

Auch die originale Quelle der Hint-Files bestätigt, dass ich nichts falsch konfiguriert habe.

Der Fehler

Wie immer bei DNS-Problemen schaut man bei DNSviz nach.

b.root-servers.net-2017-10-02-15_17_03-UTC

Alles OK? Nein, da ist doch ein kleines Warndreieck!

2017-10-02-172109_238x422_scrot

Das ist exakt die Beschreibung meines Problems: Es gibt im Internet noch Einträge, die die alte, fehlerhafte IP enthalten.

Die Ankündigung passt auch prima zu dem ersten Auftreten des Problems:

As previously announced, B-Root’s IPv6 addresswas renumbered
to 2001:500:200::b, effective 2017-06-01.
(Or IPv6 address previously was 2001:500:84::b; we will continue to
operate service onthe old address for at least 6 months.)

Problem solved?

Die Lösung

Natürlich ist das Problem nicht gelöst. Es muss jemand benachrichtigt werden, der das Problem fixed. Aber wer?

$ dig ns net +short | while read a; do
   echo -n "$a ";
   dig +nottl +nocl aaaa b.root-servers.net @$a |
     egrep -i '^b.root-servers.net.*AAAA';
 done | sort

a.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
b.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
c.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
d.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
e.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
f.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
g.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
h.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
i.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
j.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
k.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
l.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b
m.gtld-servers.net. b.root-servers.net. AAAA    2001:500:84::b

Also alle. Die gesamte Delegation von NET ist kaputt.

Verantwortlich für .net ist Versign. Und die verweisen für die Delegierung auf Verisign (im Auftrag IANA). (Ja, das hatte ich anfangs falsch.)

Domain Name: ROOT-SERVERS.NET
Registry Domain ID: 2751247_DOMAIN_NET-VRSN
Registrar WHOIS Server: whois.networksolutions.com
Registrar URL: http://www.networksolutions.com
Updated Date: 2017-03-05T17:07:51Z
Creation Date: 1995-07-04T04:00:00Z
Registrar Registration Expiration Date: 2020-07-03T04:00:00Z
Registrar: NETWORK SOLUTIONS, LLC.
Registrar IANA ID: 2
Registrar Abuse Contact Email: abuse@web.com
Registrar Abuse Contact Phone: +1.8003337680
Reseller: 
Domain Status: 
Registry Registrant ID: 
Registrant Name: VERISIGN INC.
Registrant Organization: VERISIGN INC.
Registrant Street: 12061 BLUEMONT WAY
Registrant City: RESTON
Registrant State/Province: VA
Registrant Postal Code: 20190-5684
Registrant Country: US
Registrant Phone: +1.7039481212
Registrant Phone Ext: 
Registrant Fax: +1.7039483670
Registrant Fax Ext: 
Registrant Email: noc@verisign.com
Registry Admin ID: 
Admin Name: IANA Root Management, ICANN
Admin Organization: ICANN
Admin Street: 12025 Waterfront Drive #300
Admin City: Los Angeles
Admin State/Province: CA
Admin Postal Code: 90094
Admin Country: US
Admin Phone: +1.13103015800
Admin Phone Ext: 
Admin Fax: +1.13108238649
Admin Fax Ext: 
Admin Email: kim.davies@icann.org

Also schreibe ich die beide mal an.

Beim Erstellen eines Programms, das Daten aus einem Puffer kratzt, stolperte ich über einen sehr seltsamen Fehler in C. Es kostet mich ernstlich Mühe, das Problem auch nur zu verstehen.

Das Programm

Zuerst habe ich das Problem eingedampft auf ein minimales Beispiel:

#include <stdio.h>

int main(void) {
   int len, step;
   char data[5];

   for(len = 27; sizeof(data) < len; len -= step) {
      step = sizeof(data) + 2; 
      printf("len = %3d, taking %d away.\n", len, step);
   }
}

Was tut das Programm?

  • Es gibt einen Puffer mit einer initialen Länge von 27 Byte.
  • Passt die Struktur nicht mehr in den Restpuffer, brich ab.
  • Solange die gesuchte Struktur aber noch rein passt, verarbeite sie (fehlt im Beispiel).
  • Nach der Verarbeitung verkleinere die Rest-Datenmenge um die verarbeitete Struktur plus ein passendes Alignment (hier fest 2).

Der benutzte Puffer, das Verschieben des Arbeitszeigers und die eigentliche Datenverarbeitung sind in dem Beispiel entfallen.

Überraschung

Und hier die Ausgabe:

len =  27, taking 7 away.
len =  20, taking 7 away.
len =  13, taking 7 away.
len =   6, taking 7 away.
len =  -1, taking 7 away.
len =  -8, taking 7 away.
len = -15, taking 7 away.
len = -22, taking 7 away.
len = -29, taking 7 away.
len = -36, taking 7 away.

Wow! Warum beendet sich das Programm denn nicht?

Die Größe der Struktur ist "5". Das ist ganz bestimmt nicht kleiner als "-1", oder etwa doch?

Ganz offensichtlich ist der Vergleich "5" ist kleiner als  "-1" wahr, aber warum?

Evtl. hab' ich ja irgendwelche Warnungen des Compilers übersehen?

$ cc -O2 -Wall -o t t.c
[ ... nichts ... ]
$ cc --version
FreeBSD clang version 3.9.1 (tags/RELEASE_391/final 289601) (based on LLVM 3.9.1)
Target: x86_64-unknown-freebsd11.0
Thread model: posix
InstalledDir: /usr/bin

Nein, auch nicht.

Erklärung

Wenn der C-Compiler den Wert des "signed int" mit dem Namen "len" in einen "unsigned int" verwandeln würde, dann wäre das Ergebnis erklärt. Der Vergleichwert wäre dann also bei mehr als 4 Mrd., also deutlich größer als die Länge der Struktur.

Aber warum sollt der Compiler eine solche Umwandlung durchführen?

Es bleibt nur ein Blick in die Norm:

  • In 6.5.8 "Relational operators" heißt es: If both of the operands have arithmetic type, the usual arithmetic conversions are performed.
  • In 6.3.1.8 "Usual arithmetic conversions" heißt es: If the operand that has unsigned integer type has  rank greater or equal to the rank of the type of the other operand, then the operand with signed integer type is converted to the type of the operand with  unsigned integer type.
  • In 6.3.1.1 "Boolean, characters, and integers" heißt es: The rank of any unsigned integer type  shall equal the rank of the corresponding signed integer type.

Für den Vergleich zwischen zwei normalen Integer-Typen – der eine mit, der andere ohne Vorzeichen – gilt also, dass beide Operatoren nach unsigned konvertiert werden: In diesem Fall also von -1 auf 4 Mrd.

Voraussetzung für diese Interpretation ist allerdings, dass der sizeof-Operator einen vorzeichenlosen Integer-Typ liefert. Also wieder die Norm lesen:

  • In 6.5.3.4 "The sizeof operator" heißt es: The result is an integer constant. Huch? Keine Aussage zu Vorzeichen?
  • Etwas später liest man: The value of the result is implementation-defined, and its type (an unsigned integer type) is size_t.

Man müsste also das Spiel gewinnen, wenn man die Variable "len" einen größeren Rank verpasst. Leider klappt das weder mit "long" noch mit "long long".

Erst eine beherzter Cast des sizeof-Operators nach "int" löst das Problem:

#include <stdio.h>

int main(void) {
   int len, step;
   char data[5];
   
   for(len = 27; (int)sizeof(data) < len; len -= step) {
      step = sizeof(data) + 2; 
      printf("len = %3d, taking %d away.\n", len, step);
   }
}

Und das ergibt:

len =  27, taking 7 away.
len =  20, taking 7 away.
len =  13, taking 7 away.
len =   6, taking 7 away.

Ende der Geschichte.

Wer mag, kann mal herausbekommen, ob der Umgang des Compilers im Falle von "long" und "long long" standardkonform ist. Ich habe da meine Zweifel.

Heute Nachmittag zeigte eine Kundenmaschine einen spontanen Anstieg der Auslastung. Auf den ersten Blick sah es so aus, dass einfach viel los war, Es gab so viele Anfragen auf die Webseite, dass die Limits erreicht wurden. Diese Grenzen verhindern, dass die Maschine unbenutzbar langsam wird. Dann meldete sich der Kunde und erklärte die Hintergründe.

Lastanstieg

httpd-anfragen

Ein wunderbarer Anstieg an Anfragen zu einem bestimmten Zeitpunkt. Es klingt dann exponentiell ab.

httpd-prozesse

Und er reißt das Limit der kleinen Maschine.

Mehr Prozesse zu starten, würde nur die Bearbeitung der anderen Prozesse noch weiter verlangsamen. Der dadurch entstehende Rückstau aus Datenbank, Platte etc. verschlimmert das Problem nur.

Wenn die Kiste voll beschäftigt ist, nimmt sie keine weiteren Anfragen an. Also meldet sich der Kunde.

Erklärung

Die Webseite sei jetzt genau ganz wichtig, weil man doch einen Projekttag machen würde. Und dazu habe man an sehr viele Schulen in den letzten Wochen Informationen gegeben. Da die Plätze pro Veranstaltung beschränkt sind, habe man sich eine zentrale Anmeldung eingerichtet.

Die Anmeldewebseite sieht so aus:

2017-09-14-190938_777x202_scrot

Wir hatten also eine Menge Schüler, die in einer Menge Schulen darauf warteten, zu einem festen Zeitpunkt auf eine Webseite zu klicken, deren Inhalt aus einer Datenbank generiert wird und dabei komplett fair ACID-Prinzipen ausführen will.

Kurz: Der Kunde hat einen distributed Denial of Service Angriff bestellt und bekommen.