packages feed

salmon-ops-0.1.0.0: src/Salmon/Actions/Follow/Registry/Dns.hs

{-# LANGUAGE OverloadedStrings #-}

{- | The DNS-index registry for "Salmon.Actions.Follow": DNS is the
registry's /index/, HTTP its /storage/ (the spec's recommended shape).

@--follow dns:\<zone\>@ names it. The document for label @L@ is announced by
one @TXT@ record at @\<L\>.\<zone\>@ reading

> v=salmon1 url=<https url> sha256=<hex digest of the document's bytes>

and that record's digest is the stamp: a round is one DNS lookup, and the
URL is fetched only when the digest the record carries is not the one last
seen — one UDP round-trip, cached by the record's TTL, and no connection to
the store at all while nothing changes. The body that comes back is hashed
and compared with the record; a mismatch throws 'IndexMismatch' and is a
failed round with that reason, never applied, because a store serving
something other than what the index announces is either mid-publish or
somebody else's, and neither is a document. No record is 'Absent'; the
lookup failing (no resolver reachable, a @SERVFAIL@) throws.

The resolver is a 'Resolver' — a name and one function — so that a test can
answer lookups itself. The one shipped, 'digResolver', shells out to
@dig +short@ through "Salmon.Builtin.Nodes.Binary" like every other binary
this tree drives: nothing in the tree resolves DNS today, and one @TXT@
lookup was not worth a resolver library's dependency footprint.
-}
module Salmon.Actions.Follow.Registry.Dns (
    Resolver (..),
    digResolver,
    parseDigTxt,
    IndexRecord (..),
    parseIndexRecord,
    recordName,
    dnsRegistry,
    IndexMismatch (..),
) where

import Control.Exception (Exception, throwIO)
import Data.Either (partitionEithers)
import qualified Data.Text as Text
import Data.Text (Text)
import qualified Data.Text.Encoding as Text
import Network.HTTP.Client (Manager)
import System.Process.ListLike (proc)

import Salmon.Actions.Follow (Digest (..), Fetch (..), Label, Registry (..), Stamp (..), labelText)
import Salmon.Actions.Follow.Registry.Http (fetchUrl)
import Salmon.Builtin.Nodes.Binary (Command (..))
import qualified Salmon.Builtin.Nodes.Binary as Binary
import Salmon.Reporter (silent)

-- | Who answers a @TXT@ lookup: every record's strings already joined,
-- one 'Text' per record; @[]@ for a name with none. May throw.
data Resolver = Resolver
    { resolverName :: Text
    , resolveTxt :: Text -> IO [Text]
    }

-- | @dig +short TXT \<name\>@; a non-zero exit (no server reachable) throws.
digResolver :: Resolver
digResolver =
    Resolver
        { resolverName = "dig"
        , resolveTxt = \name -> do
            out <- Binary.untrackedExecOutput dig ["+short", "TXT", Text.unpack name] "" silent
            pure (parseDigTxt (Text.decodeUtf8Lenient out))
        }
  where
    dig = Command (\args -> proc "dig" args)

{- | @dig +short@ prints one record per line as its quoted strings —
@"v=salmon1 url=..." "sha256=..."@ for a record longer than one string —
and the odd @;;@ comment on the way to a non-zero exit. Each line's strings
are unescaped and joined, as the @TXT@ RFC says a reader should.
-}
parseDigTxt :: Text -> [Text]
parseDigTxt = map (Text.concat . strings . Text.unpack) . filter (not . Text.isPrefixOf ";") . filter (not . Text.null) . Text.lines
  where
    strings :: String -> [Text]
    strings s = case dropWhile (/= '"') s of
        [] -> []
        (_ : rest) -> let (str, more) = quoted rest in Text.pack str : strings more
    quoted :: String -> (String, String)
    quoted ('\\' : c : rest) = let (s, more) = quoted rest in (c : s, more)
    quoted ('"' : rest) = ([], rest)
    quoted (c : rest) = let (s, more) = quoted rest in (c : s, more)
    quoted [] = ([], [])

-- | What a @v=salmon1@ record announces.
data IndexRecord = IndexRecord
    { indexUrl :: Text
    , indexDigest :: Digest
    }
    deriving (Show, Eq)

-- | @v=salmon1 url=... sha256=...@, whitespace-separated, in any order after
-- the version; a record for some other version, or one missing either
-- field, is a 'Left' naming what is missing.
parseIndexRecord :: Text -> Either Text IndexRecord
parseIndexRecord txt =
    case Text.words txt of
        ("v=salmon1" : fields) ->
            let pairs = [(k, Text.drop 1 v) | f <- fields, let (k, v) = Text.breakOn "=" f]
             in case (lookup "url" pairs, lookup "sha256" pairs) of
                    (Just url, Just hex)
                        | Text.length hex == 64 && Text.all isHex hex -> Right (IndexRecord url (Digest (Text.toLower hex)))
                        | otherwise -> Left ("sha256= is not a hex sha256 digest: " <> hex)
                    (Nothing, _) -> Left "no url= in the record"
                    (_, Nothing) -> Left "no sha256= in the record"
        _ -> Left ("not a v=salmon1 record: " <> txt)
  where
    isHex c = c `elem` ("0123456789abcdefABCDEF" :: String)

-- | The name looked up for a label: @\<label\>.\<zone\>@.
recordName :: Text -> Label -> Text
recordName zone lbl = labelText lbl <> "." <> Text.dropWhileEnd (== '.') zone

-- | The index said one thing and the store served another.
data IndexMismatch = IndexMismatch
    { mismatchName :: Text
    , mismatchUrl :: Text
    , mismatchAnnounced :: Digest
    , mismatchServed :: Digest
    }

instance Show IndexMismatch where
    show m =
        Text.unpack $
            "the document at "
                <> m.mismatchUrl
                <> " does not hash to what the index record "
                <> m.mismatchName
                <> " announces (record: sha256="
                <> Text.take 12 m.mismatchAnnounced.unDigest
                <> ", served: sha256="
                <> Text.take 12 m.mismatchServed.unDigest
                <> ")"

instance Exception IndexMismatch

-- | A registry over a zone, named @dns:\<zone\>@.
dnsRegistry :: Resolver -> Manager -> Text -> Registry
dnsRegistry resolver mgr zone =
    Registry
        { registryName = "dns:" <> zone
        , registryFetch = \lbl previous -> do
            let name = recordName zone lbl
            txts <- resolveTxt resolver name
            if null txts
                then pure Absent
                else case partitionEithers (map parseIndexRecord txts) of
                    (errs, []) -> ioError (userError (Text.unpack (name <> " has " <> Text.pack (show (length txts)) <> " TXT record(s) and none is a salmon index: " <> Text.intercalate "; " errs)))
                    (_, record : _) -> do
                        let stamp = Stamp ("sha256:" <> record.indexDigest.unDigest)
                        if Just stamp == previous
                            then pure Unchanged
                            else do
                                -- unconditional: the record already said it moved
                                fetched <- fetchUrl mgr record.indexUrl Nothing
                                case fetched of
                                    Found _ digest bytes
                                        | digest == record.indexDigest -> pure (Found stamp digest bytes)
                                        | otherwise -> throwIO (IndexMismatch name record.indexUrl record.indexDigest digest)
                                    Absent -> ioError (userError (Text.unpack (name <> " points at " <> record.indexUrl <> ", which has no document")))
                                    Unchanged -> ioError (userError (Text.unpack (record.indexUrl <> " answered 304 to an unconditional request")))
        }