microdns-0.1.0.0: src/MicroDNS/DynamicRegistration.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}
-- TODO:
-- turned HashedPart in some nonces to limit replay risks
module MicroDNS.DynamicRegistration where
import Control.Monad.IO.Class (liftIO)
import qualified Crypto.Hash.SHA256 as HMAC256
import Data.Aeson (FromJSON, ToJSON)
import Data.ByteString (ByteString)
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Base16 as Base16
import qualified Data.ByteString.Char8 as C8
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.IP as IP
import qualified Data.List as List
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import GHC.Generics (Generic)
import Net.IPv4 as IPv4
import Net.IPv6 as IPv6
import qualified Network.DNS as DNS
import Network.Socket (SockAddr)
import Prod.Background as Background
import Prod.Tracer (Tracer (..), contramap)
import qualified Prod.Tracer as Tracer
import qualified Prometheus as Prometheus
import Servant
import Servant.Server
import MicroDNS.Handler (Apex (..), apexFromText)
type Api =
AutoRegisterApi
:<|> RegisterTextApi
:<|> RegisterAApi
:<|> RegisterAAAAApi
:<|> ListRegistrationsApi
type DNSLeafName = Text
type AutoRegisterApi =
Summary "registers a unique A record"
:> "register"
:> "auto"
:> Capture "dns-leaf" DNSLeafName
:> QueryParam "apex" Text
:> RemoteHost
:> Header "x-forwarded-for" Text
:> Header "x-microdns-hmac" ChallengeAttempt
:> Post '[JSON] AutoRegistrationResult
type RegisterTextApi =
Summary "registers a TXT (e.g., for an ACME challenge)"
:> "register"
:> "txt"
:> Capture "dns-leaf" DNSLeafName
:> Capture "token" Text
:> QueryParam "apex" Text
:> Header "x-microdns-hmac" ChallengeAttempt
:> Post '[JSON] (DNSLeafName, Text)
type RegisterAApi =
Summary "registers a A"
:> "register"
:> "a"
:> Capture "dns-leaf" DNSLeafName
:> Capture "token" Text
:> QueryParam "apex" Text
:> Header "x-microdns-hmac" ChallengeAttempt
:> Post '[JSON] (DNSLeafName, Text)
type RegisterAAAAApi =
Summary "registers a AAAA"
:> "register"
:> "aaaa"
:> Capture "dns-leaf" DNSLeafName
:> Capture "token" Text
:> QueryParam "apex" Text
:> Header "x-microdns-hmac" ChallengeAttempt
:> Post '[JSON] (DNSLeafName, Text)
type ListRegistrationsApi =
Summary "list registrations"
:> "registrations"
:> Header "x-microdns-hmac" ChallengeAttempt
:> Get '[JSON] Registrations
type ChallengeAttempt = Text
data AutoRegistrationResult
= AutoRegistrationResult
{ registeredLeaf :: DNSLeafName
, registeredIP :: Text
}
deriving (Show, Generic)
instance ToJSON AutoRegistrationResult
data RegistrationFailedReason
= AuthError
| ProxiedError
| IPLookupError
| IPParsingError
deriving (Show)
data Registrations
= Registrations
{ registrations :: [String]
}
deriving (Show, Generic)
instance ToJSON Registrations
data Trace
= RegistrationSuccess DNS.ResourceRecord
| RegistrationFailed RegistrationFailedReason
deriving (Show)
data Counters
= Counters
{ cnt_registrations :: Prometheus.Vector Text Prometheus.Counter
, cnt_records :: Prometheus.Gauge
}
initCounters :: IO Counters
initCounters =
Counters
<$> Prometheus.register
( Prometheus.vector
("status")
(Prometheus.counter (Prometheus.Info "dyn_registrations" "number of DNS registrations"))
)
<*> Prometheus.register
(Prometheus.gauge (Prometheus.Info "dyn_records" "number of DNS records"))
type SharedSecret = ByteString
data Runtime = Runtime
{ dnsApex :: Apex
, sharedHmacSecret :: SharedSecret
, counters :: Counters
, rrs :: IORef [DNS.ResourceRecord]
, tracer :: Tracer IO Trace
, background :: BackgroundVal ()
}
initRuntime :: Tracer IO Trace -> SharedSecret -> Apex -> IO Runtime
initRuntime tracer secret apex = do
counters <- initCounters
rrs <- newIORef []
let updateCounters = Prometheus.setGauge (cnt_records counters) . fromIntegral . length =<< readIORef rrs
bkg <- backgroundLoop Tracer.silent () updateCounters 5000000
pure $ Runtime apex secret counters rrs tracer bkg
readRRs :: Runtime -> IO [DNS.ResourceRecord]
readRRs = readIORef . rrs
addRRa :: ActionAuthorized -> Runtime -> Apex -> DNSLeafName -> IP.IP -> IO DNS.ResourceRecord
addRRa _ rt apex leaf val = do
atomicModifyIORef' (rrs rt) (\xs -> (insertRR newRecord xs, ()))
pure newRecord
where
fqdn :: ByteString
fqdn = mconcat [Text.encodeUtf8 leaf, ".", getApex apex]
newRecord :: DNS.ResourceRecord
newRecord = case val of
IP.IPv4 val -> DNS.ResourceRecord fqdn DNS.A DNS.classIN 300 $ DNS.RD_A val
IP.IPv6 val -> DNS.ResourceRecord fqdn DNS.AAAA DNS.classIN 300 $ DNS.RD_AAAA val
insertRR :: DNS.ResourceRecord -> [DNS.ResourceRecord] -> [DNS.ResourceRecord]
insertRR x xs = x : List.filter (otherFqdn x) xs
otherFqdn :: DNS.ResourceRecord -> DNS.ResourceRecord -> Bool
otherFqdn
(DNS.ResourceRecord qdn2 _ _ _ _)
(DNS.ResourceRecord qdn1 _ _ _ _) = qdn1 /= qdn2
addText :: ActionAuthorized -> Runtime -> Apex -> DNSLeafName -> Text -> IO DNS.ResourceRecord
addText _ rt apex leaf val = do
atomicModifyIORef' (rrs rt) (\xs -> (insertRR newRecord xs, ()))
pure newRecord
where
fqdn :: ByteString
fqdn = mconcat [Text.encodeUtf8 leaf, ".", getApex apex]
newRecord :: DNS.ResourceRecord
newRecord =
DNS.ResourceRecord fqdn DNS.TXT DNS.classIN 300 $ DNS.RD_TXT $ Text.encodeUtf8 val
handleDynamicRegistration :: Runtime -> Server Api
handleDynamicRegistration runtime =
handleAutoRegister runtime
:<|> handleTextRegister runtime
:<|> handleARegister runtime
:<|> handleAAAARegister runtime
:<|> handleListRegistrations runtime
handleAutoRegister :: Runtime -> DNSLeafName -> Maybe Text -> SockAddr -> Maybe Text -> Maybe ChallengeAttempt -> Handler AutoRegistrationResult
handleAutoRegister rt _ _ _ (Just _) _ = do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed ProxiedError
throwError err400
handleAutoRegister rt _ _ _ _ Nothing = do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed AuthError
throwError err403
handleAutoRegister rt dnsleaf apex sockaddr Nothing (Just hmac) = do
let hashedpart = (apex, dnsleaf)
let auth = verifyHmac rt hashedpart hmac
let ipport = IP.fromSockAddr sockaddr
case auth of
Nothing -> do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "auth-error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed AuthError
throwError err403
Just success -> do
case ipport of
Nothing -> do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed IPLookupError
throwError err500
Just (ip, _) -> liftIO $ do
rr <- addRRa success rt (maybe (dnsApex rt) apexFromText apex) dnsleaf ip
runTracer (tracer rt) $ RegistrationSuccess rr
Prometheus.withLabel (cnt_registrations $ counters rt) "success" Prometheus.incCounter
pure $ AutoRegistrationResult dnsleaf (Text.pack $ show ip)
handleTextRegister :: Runtime -> DNSLeafName -> Text -> Maybe Text -> Maybe ChallengeAttempt -> Handler (DNSLeafName, Text)
handleTextRegister rt _ _ _ Nothing = do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "auth-error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed AuthError
throwError err403
handleTextRegister rt dnsleaf textval apex (Just hmac) = do
let hashedpart = (apex, dnsleaf)
let auth = verifyHmac rt hashedpart hmac
case auth of
Nothing -> do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "auth-error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed AuthError
throwError err403
Just success -> liftIO $ do
rr <- addText success rt (maybe (dnsApex rt) apexFromText apex) dnsleaf textval
runTracer (tracer rt) $ RegistrationSuccess rr
pure (dnsleaf, textval)
handleARegister :: Runtime -> DNSLeafName -> Text -> Maybe Text -> Maybe ChallengeAttempt -> Handler (DNSLeafName, Text)
handleARegister rt _ _ _ Nothing = do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "auth-error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed AuthError
throwError err403
handleARegister rt dnsleaf textval apex (Just hmac) = do
let hashedpart = (apex, dnsleaf)
let auth = verifyHmac rt hashedpart hmac
case auth of
Nothing -> do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "auth-error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed AuthError
throwError err403
Just success -> do
let ipv4 = IPv4.decode textval
case ipv4 of
Nothing -> do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed IPParsingError
throwError err400
Just ip -> liftIO $ do
rr <- addRRa success rt (maybe (dnsApex rt) apexFromText apex) dnsleaf (IP.IPv4 $ IP.fromHostAddress $ IPv4.getIPv4 ip)
runTracer (tracer rt) $ RegistrationSuccess rr
pure (dnsleaf, textval)
handleAAAARegister :: Runtime -> DNSLeafName -> Text -> Maybe Text -> Maybe ChallengeAttempt -> Handler (DNSLeafName, Text)
handleAAAARegister rt _ _ _ Nothing = do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "auth-error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed AuthError
throwError err403
handleAAAARegister rt dnsleaf textval apex (Just hmac) = do
let hashedpart = (apex, dnsleaf)
let auth = verifyHmac rt hashedpart hmac
case auth of
Nothing -> do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "auth-error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed AuthError
throwError err403
Just success -> do
let ipv6 = IPv6.decode textval
case ipv6 of
Nothing -> do
liftIO $ do
Prometheus.withLabel (cnt_registrations $ counters rt) "error" Prometheus.incCounter
runTracer (tracer rt) $ RegistrationFailed IPParsingError
throwError err400
Just ip -> liftIO $ do
rr <- addRRa success rt (maybe (dnsApex rt) apexFromText apex) dnsleaf (IP.IPv6 $ IP.fromHostAddress6 $ IPv6.toWord32s ip)
runTracer (tracer rt) $ RegistrationSuccess rr
pure (dnsleaf, textval)
handleListRegistrations :: Runtime -> Maybe ChallengeAttempt -> Handler Registrations
handleListRegistrations rt _ = do
rrs <- liftIO $ readRRs rt
pure $ Registrations $ map show rrs
-- (apex?, txt)
type HashedPart = (Maybe Text, Text)
data ActionAuthorized = ActionAuthorized
verifyHmac :: Runtime -> HashedPart -> ChallengeAttempt -> Maybe ActionAuthorized
verifyHmac rt (apex, txt) attempt =
if attempt == expected
then Just ActionAuthorized
else Nothing
where
hashedStr :: ByteString
hashedStr = mconcat [maybe "" Text.encodeUtf8 apex, Text.encodeUtf8 txt]
hmac = HMAC256.hmac (sharedHmacSecret rt) hashedStr
expected = Text.decodeUtf8 $ Base16.encode $ hmac