packages feed

microdns-0.1.0.0: src/MicroDNS/MicroZone.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}

module MicroDNS.MicroZone (
    ZoneFile (..),
    Directive (..),

    -- * parsing
    zoneFile,

    -- * exploitation
    collectDirectives,
) where

import qualified Data.ByteString as ByteString
import Data.CaseInsensitive as CI
import Data.IP (IPv4, IPv6)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.Void (Void)
import Data.Word (Word16)
import qualified Network.DNS as DNS
import Text.Megaparsec
import Text.Megaparsec.Char (alphaNumChar, digitChar, hexDigitChar, newline, space, string)

import MicroDNS.Handler (Apex (..), apexFromText, apexify, endsWithDot)

data ZoneFile
    = ZoneFile
    { directives :: [Directive]
    }
    deriving (Show)

type CommentedText = Text

data GuardedCommand
    = SetApex Text
    deriving (Show)

data Directive
    = Comment CommentedText
    | Command GuardedCommand
    | Record DNS.ResourceRecord
    deriving (Show)

type Parser = Parsec Void Text

zoneFile :: Parser ZoneFile
zoneFile =
    ZoneFile <$> (directive `sepEndBy` skipSome newline) <* eof

directive :: Parser Directive
directive =
    choice
        [ (Comment <$> comment)
        , (Command <$> command)
        , (Record <$> record)
        ]

comment :: Parser CommentedText
comment = string "--" *> space *> takeWhile1P Nothing ((/=) '\n')

command :: Parser GuardedCommand
command =
    choice
        [ SetApex <$> apex
        ]
  where
    apex = string "microdns:apex" *> space *> domainName

record :: Parser DNS.ResourceRecord
record =
    choice
        [ txtRecord
        , -- aaaa first to avoid a valid 'a' prefix parse
          aaaaRecord
        , aRecord
        , caaRecord
        , cnameRecord
        , mxRecord
        , srvRecord
        ]

txtRecord :: Parser DNS.ResourceRecord
txtRecord = do
    _ <- string "TXT"
    _ <- space
    !domain <- Text.encodeUtf8 <$> apexOrDomainName
    _ <- space
    !val <- Text.encodeUtf8 <$> quotedString
    pure $ DNS.ResourceRecord domain DNS.TXT DNS.classIN 300 $ DNS.RD_TXT val

aRecord :: Parser DNS.ResourceRecord
aRecord = do
    _ <- string "A"
    _ <- space
    !domain <- Text.encodeUtf8 <$> apexOrDomainName
    _ <- space
    !val <- ipv4
    pure $ DNS.ResourceRecord domain DNS.A DNS.classIN 300 $ DNS.RD_A val

aaaaRecord :: Parser DNS.ResourceRecord
aaaaRecord = do
    _ <- string "AAAA"
    _ <- space
    !domain <- Text.encodeUtf8 <$> apexOrDomainName
    _ <- space
    !val <- ipv6
    pure $ DNS.ResourceRecord domain DNS.AAAA DNS.classIN 300 $ DNS.RD_AAAA val

caaRecord :: Parser DNS.ResourceRecord
caaRecord = do
    _ <- string "CAA"
    _ <- space
    !domain <- Text.encodeUtf8 <$> apexOrDomainName
    _ <- space
    !key <- CI.mk . Text.encodeUtf8 <$> quotedString
    _ <- space
    !val <- Text.encodeUtf8 <$> quotedString
    pure $ DNS.ResourceRecord domain DNS.CAA DNS.classIN 300 $ DNS.RD_CAA 0 key val

cnameRecord :: Parser DNS.ResourceRecord
cnameRecord = do
    _ <- string "CNAME"
    _ <- space
    !domain <- Text.encodeUtf8 <$> apexOrDomainName
    _ <- space
    !val <- Text.encodeUtf8 <$> domainName
    pure $ DNS.ResourceRecord domain DNS.CNAME DNS.classIN 300 $ DNS.RD_CNAME val

mxRecord :: Parser DNS.ResourceRecord
mxRecord = do
    _ <- string "MX"
    _ <- space
    !domain <- Text.encodeUtf8 <$> apexOrDomainName
    _ <- space
    !priority <- mxPriority
    _ <- space
    !val <- Text.encodeUtf8 <$> domainName
    pure $ DNS.ResourceRecord domain DNS.MX DNS.classIN 300 $ DNS.RD_MX priority val

srvRecord :: Parser DNS.ResourceRecord
srvRecord = do
    _ <- string "SRV"
    _ <- space
    !domain <- Text.encodeUtf8 <$> apexOrDomainName
    _ <- space
    !priority <- srvPriority
    _ <- space
    !port <- srvPortnum
    _ <- space
    !val <- Text.encodeUtf8 <$> domainName
    pure $ DNS.ResourceRecord domain DNS.SRV DNS.classIN 300 $ DNS.RD_SRV priority 1 port val

apexOrDomainName :: Parser Text
apexOrDomainName =
    (string "@" *> pure "") <|> domainName

domainName :: Parser Text
domainName =
    Text.pack
        <$> many (alphaNumChar <|> oneOf ['.', '-', '_'])

quotedString :: Parser Text
quotedString =
    Text.pack
        <$> between (string "\"") (string "\"") contents
  where
    contents :: Parser [Char]
    contents = many (escapedChar <|> plainChar)

    escapedChar :: Parser Char
    escapedChar = backspace <|> quote

    backspace :: Parser Char
    backspace = string "\\\\" *> pure '\\'

    quote :: Parser Char
    quote = string "\\\"" *> pure '"'

    plainChar :: Parser Char
    plainChar = noneOf ['\\', '"']

ipv4 :: Parser IPv4
ipv4 = read <$> many (digitChar <|> oneOf ['.'])

ipv6 :: Parser IPv6
ipv6 = read <$> many (hexDigitChar <|> oneOf [':', '.'])

mxPriority, srvPriority, srvPortnum :: Parser Word16
mxPriority = read <$> many (digitChar)
srvPriority = read <$> many (digitChar)
srvPortnum = read <$> many (digitChar)

collectDirectives :: Apex -> ZoneFile -> [DNS.ResourceRecord]
collectDirectives defaultApex zones =
    go defaultApex [] (directives zones)
  where
    go :: Apex -> [DNS.ResourceRecord] -> [Directive] -> [DNS.ResourceRecord]
    go _ xs [] = xs
    go a xs ((Comment _) : ds) = go a xs ds
    go _ xs ((Command (SetApex a)) : ds) = go (apexFromText a) xs ds
    go a xs ((Record rec) : ds) = go a (adapt a rec : xs) ds

    adapt :: Apex -> DNS.ResourceRecord -> DNS.ResourceRecord
    adapt apex rr
        | matchSuffix apex rr = rr
        | otherwise = rr{DNS.rrname = addApex apex (DNS.rrname rr)}

    addApex :: Apex -> ByteString.ByteString -> ByteString.ByteString
    addApex apex "" = getApex apex
    addApex apex bs = if endsWithDot bs then mconcat [bs, getApex apex] else mconcat [bs, ".", getApex apex]

    matchSuffix :: Apex -> DNS.ResourceRecord -> Bool
    matchSuffix apex rr = getApex apex `ByteString.isSuffixOf` getApex (apexify $ DNS.rrname rr)