salmon-ops-0.2.0.0: src/Salmon/Builtin/Nodes/PortMapping/Upnpc.hs
{-# LANGUAGE OverloadedStrings #-}
{- | Pure parsers for @upnpc@ (miniupnpc) output, and the classification of
the external address it reports.
Kept apart from "Salmon.Builtin.Nodes.PortMapping" so that anything else that
wants to know what a gateway offers (a capabilities report, a probe) can
import the parsers without importing a node.
The formats are those of miniupnpc 2.2.6. The "device answered but is not an
Internet Gateway Device" and "nothing answered" outputs were captured from a
real LAN; the listing format follows @upnpc.c@'s @-l@ and has not been seen
against a live IGD by the author of this module.
-}
module Salmon.Builtin.Nodes.PortMapping.Upnpc (
Gateway (..),
parseGateway,
Mapping (..),
Proto (..),
protoText,
parseMappings,
AddressClass (..),
classifyAddress,
) where
import Data.Char (isDigit, isSpace)
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import Text.Read (readMaybe)
data Proto = TCP | UDP
deriving (Eq, Show)
protoText :: Proto -> Text
protoText TCP = "TCP"
protoText UDP = "UDP"
-- | What discovery found.
data Gateway
= -- | nothing answered the multicast search
NoDevice
| -- | something answered, but it is not an Internet Gateway Device (e.g. a WPS @WFADevice.xml@)
NotIgd
| -- | a gateway, with the external address it reports and this host's LAN address when printed
Igd {gwExternal :: Maybe Text, gwLocal :: Maybe Text}
deriving (Eq, Show)
-- | Reads the output (stdout and stderr together) of @upnpc -s@ or @upnpc -l@.
parseGateway :: Text -> Gateway
parseGateway out
| noIgd && deviceSeen = NotIgd
| noIgd = NoDevice
| otherwise = Igd (valueAfter "ExternalIPAddress = ") (valueAfter "Local LAN ip address : ")
where
ls = fmap Text.strip (Text.lines out)
noIgd =
any
(\l -> "No valid UPNP Internet Gateway Device found" `Text.isInfixOf` l || "No IGD UPnP Device found" `Text.isInfixOf` l)
ls
deviceSeen = any ("UPnP device found. Is it an IGD" `Text.isInfixOf`) ls
valueAfter key = case [Text.strip v | l <- ls, Just v <- [Text.stripPrefix key l]] of
(v : _) | not (Text.null v) -> Just v
_ -> Nothing
-- | One line of the redirection listing.
data Mapping = Mapping
{ mapProto :: Proto
, mapExternalPort :: Int
, mapInternalAddress :: Text
, mapInternalPort :: Int
, mapDescription :: Text
, mapRemoteHost :: Text
, mapLease :: Int
-- ^ seconds, 0 for permanent
}
deriving (Eq, Show)
{- | The mappings in @upnpc -l@ output: lines of the form
@ 0 UDP 51820->192.168.1.5:51820 'description' 'remote host' 3600@.
Lines that are not of that shape are skipped.
-}
parseMappings :: Text -> [Mapping]
parseMappings = mapMaybe parseLine . Text.lines
parseLine :: Text -> Maybe Mapping
parseLine l0 = do
let (idx, r1) = Text.break isSpace (Text.stripStart l0)
if not (Text.null idx) && Text.all isDigit idx then Just () else Nothing
let (protoTxt, r2) = Text.break isSpace (Text.stripStart r1)
proto <- case protoTxt of
"TCP" -> Just TCP
"UDP" -> Just UDP
_ -> Nothing
let (spec, r3) = Text.break isSpace (Text.stripStart r2)
(extTxt, afterArrow) = Text.breakOn "->" spec
extPort <- readMaybe (Text.unpack extTxt)
target <- Text.stripPrefix "->" afterArrow
let (addrColon, portTxt) = Text.breakOnEnd ":" target
inPort <- readMaybe (Text.unpack portTxt)
inAddr <- Text.stripSuffix ":" addrColon
-- the rest is 'description' 'remote host' lease
let (quoted, leaseTxt) = Text.breakOnEnd "'" (Text.strip r3)
lease <- readMaybe (Text.unpack (Text.strip leaseTxt))
body <- Text.stripPrefix "'" quoted
body' <- Text.stripSuffix "'" body
let (beforeRemote, remote) = Text.breakOnEnd "'" body'
desc = Text.dropWhileEnd (\c -> c == '\'' || isSpace c) beforeRemote
pure (Mapping proto extPort inAddr inPort desc remote lease)
data AddressClass
= Public
| -- | RFC 1918, loopback or link-local: the gateway is itself behind a NAT
Private
| -- | 100.64.0.0/10, carrier-grade NAT: no mapping on this router can be reached
CGNAT
| Unparseable
deriving (Eq, Show)
classifyAddress :: Text -> AddressClass
classifyAddress t = case traverse (readMaybe . Text.unpack) (Text.splitOn "." (Text.strip t)) :: Maybe [Int] of
Just [a, b, c, d]
| all (\x -> x >= 0 && x <= 255) [a, b, c, d] -> go a b
_ -> Unparseable
where
go :: Int -> Int -> AddressClass
go 10 _ = Private
go 127 _ = Private
go 172 b | b >= 16 && b <= 31 = Private
go 192 168 = Private
go 169 254 = Private
go 100 b | b >= 64 && b <= 127 = CGNAT
go _ _ = Public