packages feed

danecheck-1.0.0.0: Dane/Scanner/Opts.hs

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE RecordWildCards #-}

module Dane.Scanner.Opts
    ( Opts(..)
    , SigAlgs(..)
    , SigAlgGroup(..)
    , getOpts
    ) where

import           Options.Applicative
import           Data.Char (isSpace, toLower)
import           Data.List (intercalate)
import           Data.Word (Word16)

-- | Named signature-algorithm groups offered to the server.
-- Each group expands at TLS-config time to a list of
-- @(HashAlgorithm, SignatureAlgorithm)@ pairs covering the
-- common SHA-256\/384\/512 variants within the family.  The
-- friendly group names give users a stable handle to refer to a
-- family without having to spell out (or remember) the individual
-- sig-alg codepoint names.  Future post-quantum families (e.g. an
-- @mldsa@ group covering ML-DSA-44\/65\/87) would slot in here
-- as additional constructors.
data SigAlgGroup = SigEcdsa  -- ^ ECDSA over P-256, P-384, P-521
                 | SigRsa    -- ^ RSA-PSS (TLS 1.3) + RSA-PKCS#1 (TLS 1.2)
                 | SigEdDsa  -- ^ Ed25519 and Ed448
    deriving (Eq, Show)

-- | An ordered list of 'SigAlgGroup's, expressing the relative
-- preference offered to the server.  The empty list means \"use
-- the TLS library default\", i.e. no override.  A non-empty list
-- both restricts the offered algorithms to the named groups and
-- pins their relative order.  Hosts that publish certificates of
-- multiple kinds can therefore be probed independently by passing
-- a single group, or with a chosen preference order by passing
-- two or more groups.
newtype SigAlgs = SigAlgs { sigAlgsList :: [SigAlgGroup] }
    deriving (Eq, Show)

data Opts = Opts
  { dnsServer   :: !(Maybe String)
  , dnsTimeout  :: !Int
  , dnsTries    :: !Int
  , dnsUdpSize  :: !Word16
  , smtpHelo    :: !(Maybe String)
  , smtpTimeout :: !Int
  , smtpLineLen :: !Int
  , useReserved :: !Bool
  , downMX      :: !([String])
  , upsideDown  :: !Bool
  , enableV4    :: !Bool
  , enableV6    :: !Bool
  , useAll      :: !Bool
  , addDays     :: !Int
  , eeChecks    :: !Bool
  , sigAlgs     :: !SigAlgs
  , dnsDomain   :: !String
  }

parseN :: Parser (Maybe String)
parseN = flag' Nothing
    ( short 'N'
   <> help "Use /etc/resolv.conf nameserver list" )

parsen :: Parser (Maybe String)
parsen = Just <$> strOption
    ( long "nameserver"
   <> short 'n'
   <> value "127.0.0.1"
   <> showDefault
   <> metavar "ADDRESS"
   <> help "Use nameserver at ADDRESS" )

parser :: Parser Opts
parser = do
    dnsServer <- parseN <|> parsen

    dnsTimeout <- option auto
      ( long "timeout"
     <> short 't'
     <> metavar "TIMEOUT"
     <> value 5000
     <> showDefaultWith (\t -> show t ++ " ms")
     <> help "DNS request TIMEOUT" )

    dnsTries <- option auto
      ( long "tries"
     <> short 'r'
     <> metavar "NUMTRIES"
     <> value 3
     <> showDefault
     <> help "at most NUMTRIES requests per lookup" )

    dnsUdpSize <- option auto
      ( long "udpsize"
     <> short 'u'
     <> metavar "SIZE"
     <> value 1400
     <> showDefault
     <> help "set EDNS UDP buffer SIZE" )

    smtpHelo <- optional ( strOption
      ( long "helo"
     <> short 'H'
     <> metavar "HELO"
     <> help "send specified client HELO name" ) )

    smtpTimeout <- option auto
      ( long "smtptimeout"
     <> short 's'
     <> metavar "TIMEOUT"
     <> value 30000
     <> showDefaultWith (\t -> show t ++ " ms")
     <> help "SMTP TIMEOUT" )

    smtpLineLen <- option auto
      ( long "linelimit"
     <> short 'l'
     <> metavar "LENGTH"
     <> value 4096
     <> showDefault
     <> help "Maximum server SMTP response LENGTH" )

    useReserved <- switch
      ( long "reserved"
     <> short 'R'
     <> help "connect to reserved IP addresses" )

    downMX <- many
      ( (map toLower) <$> strOption
        ( long "down"
       <> short 'D'
       <> metavar "HOSTNAME"
       <> help "Specify one or more HOSTNAMEs that are down" ) )

    upsideDown <- switch
      ( long "down-only"
     <> short 'U'
     <> help "Limit connections to just the '-D' option hosts" )

    enableV4 <- not <$> switch
      ( long "noipv4"
     <> short '4'
     <> help "disable SMTP via IPv4" )

    enableV6 <- not <$> switch
      ( long "noipv6"
     <> short '6'
     <> help "disable SMTP via IPv6" )

    useAll <- switch
      ( long "all"
     <> short 'A'
     <> help "scan all MX hosts, not just those with TLSA RRs" )

    addDays <- option auto
      ( long "days"
     <> short 'd'
     <> metavar "DAYS"
     <> value 0
     <> help "check validity at DAYS in the future" )

    eeChecks <- switch
      ( long "eechecks"
     <> short 'e'
     <> help "check end-entity (leaf) certificate dates and names" )

    sigAlgs <- option (eitherReader parseSigAlgs)
      ( long "sigalgs"
     <> metavar "GROUPS"
     <> value (SigAlgs [])
     <> showDefaultWith showSigAlgs
     <> help "Comma-separated ordered list of TLS signature-algorithm \
             \groups, drawn from 'rsa', 'ecdsa' and 'eddsa'; restricts \
             \the offered algorithms and pins their relative order.  \
             \Use 'any' or the empty string for the TLS library \
             \defaults (the default)." )

    dnsDomain <- strArgument
      ( metavar "DOMAIN"
     <> value "."
     <> showDefault
     <> help "check the specified DOMAIN" )

    return Opts{..}

-- | Parse the @--sigalgs@ argument:  a comma-separated, case-
-- insensitive list of named groups, with whitespace tolerated.
-- The literal string @any@ (or an empty string) yields
-- @SigAlgs []@, meaning \"no override\".
parseSigAlgs :: String -> Either String SigAlgs
parseSigAlgs s
    | null trimmed                    = Right (SigAlgs [])
    | map toLower trimmed == "any"    = Right (SigAlgs [])
    | otherwise = SigAlgs <$> traverse parseOne (splitComma trimmed)
  where
    trimmed = dropWhile isSpace $ reverse $ dropWhile isSpace $ reverse s
    splitComma xs = case break (== ',') xs of
        (h, ',':t) -> trim h : splitComma t
        (h, _)     -> [trim h]
    trim = dropWhile isSpace . reverse . dropWhile isSpace . reverse
    parseOne g = case map toLower g of
        "rsa"   -> Right SigRsa
        "ecdsa" -> Right SigEcdsa
        "eddsa" -> Right SigEdDsa
        ""      -> Left "empty sig-alg group name"
        _       -> Left $ "unknown sig-alg group: " ++ show g
                       ++ " (expected one of rsa, ecdsa, eddsa)"

-- | Pretty-print a 'SigAlgs' value for the @--help@ default
-- display.
showSigAlgs :: SigAlgs -> String
showSigAlgs (SigAlgs []) = "any"
showSigAlgs (SigAlgs gs) = intercalate "," (map groupName gs)
  where
    groupName SigRsa   = "rsa"
    groupName SigEcdsa = "ecdsa"
    groupName SigEdDsa = "eddsa"

-- | Parse command-line switches
--
getOpts :: IO Opts
getOpts =
  execParser
    $ info (helper <*> parser)
    $ noIntersperse
    <> fullDesc
    <> header "danecheck - check for and validate SMTP TLSA records"
    <> footer
       ("When scanning the root domain, what's checked is secure retrieval of \
       \ the root DNSKEY and SOA RRSets. Similarly, when scanning a top-level \
       \ domain, what's checked is secure retrieval of its DS, DNSKEY and SOA \
       \ records. For all other domains, MX records, address records and TLSA \
       \ records are retrieved and must be DNSSEC signed. Each MX host is     \
       \ expected to have TLSA records, an SMTP connection is made to each    \
       \ address of each such MX host (with the '-A' option connections are   \
       \ made to all MX hosts). A TLS handshake is performed to retrieve the  \
       \ hosts's certificate chain which is verified against the DNS TLSA RRs \
       \ If anything is unavailable, insecure or wrong, a non-zero exit code  \
       \ is returned. The '-D' option can be used multiple times to skip SMTP \
       \ connections to MX hosts that are expected to be down. The '-U' option\
       \ inverts the action of the '-D' option, and connects to only those MX \
       \ hosts that are specified via the '-D' option (none if no such hosts  \
       \ are specified)."
       )