ephemeral-pg-0.3.0.0: src/EphemeralPg/Internal/ProcessIdentity.hs
-- | Conservative process inspection. Unknown observations never authorize work.
module EphemeralPg.Internal.ProcessIdentity
( Observation (..),
Identity (..),
Inspector (..),
systemInspector,
PidRecord (..),
parsePidRecord,
matchesServer,
isPostgres,
)
where
import Control.Exception (IOException, catch)
import Control.Monad (unless)
import Data.ByteString.Char8 qualified as BS
import Data.List (intercalate, isPrefixOf)
import Data.Time (UTCTime, defaultTimeLocale, parseTimeM)
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.FilePath (takeFileName)
import System.IO.Error (isDoesNotExistError)
import System.Info (os)
import System.Posix.Files (readSymbolicLink)
import System.Posix.Signals (nullSignal, sigINT, signalProcess)
import System.Posix.Types (ProcessID, UserID)
import System.Posix.User (getEffectiveUserID)
import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode)
import Text.Read (readMaybe)
data Identity = Identity
{ pid :: ProcessID,
parent :: ProcessID,
uid :: UserID,
started :: Integer,
command :: String,
arguments :: String,
workingDirectory :: Maybe FilePath,
zombie :: Bool
}
deriving stock (Eq, Show)
data Observation = Gone | Present Identity | Unknown String deriving stock (Eq, Show)
data Inspector = Inspector
{ inspect :: ProcessID -> IO Observation,
enumerate :: IO (Either String [Identity]),
interrupt :: ProcessID -> IO ()
}
data PidRecord = PidRecord {pid :: ProcessID, path :: FilePath, started :: Integer}
deriving stock (Eq, Show)
parsePidRecord :: String -> Maybe PidRecord
parsePidRecord text = case lines text of
p : path : start : port : socket : _listen : memory : status : _ -> do
n <- readMaybe p :: Maybe Integer
t <- readMaybe start
portNumber <- readMaybe port :: Maybe Int
if length text <= 8192
&& n > 1
&& n <= toInteger (maxBound :: ProcessID)
&& t > 0
&& not (null path)
&& portNumber > 0
&& portNumber <= 65535
&& not (null socket)
&& not (null memory)
&& words status `elem` [["ready"], ["starting"], ["stopping"]]
then Just (PidRecord (fromInteger n) path t)
else Nothing
_ -> Nothing
matchesServer :: UserID -> PidRecord -> Identity -> Bool
matchesServer uid record ident =
ident.pid == record.pid
&& ident.uid == uid
&& takeFileName ident.command == "postgres"
&& ident.workingDirectory == Just record.path
&& abs (ident.started - record.started) <= 2
&& (ident.command <> " -D " <> record.path <> " -k ") `isPrefixOf` ident.arguments
systemInspector :: Inspector
systemInspector = Inspector inspectProcess enumerateProcesses (signalProcess sigINT)
-- Linux installations such as NixOS provide procps through PATH.
psExecutable :: FilePath
psExecutable = if os == "darwin" then "/bin/ps" else "ps"
-- Only promote a platform after running the real orphan fixture there.
enumerateProcesses :: IO (Either String [Identity])
enumerateProcesses = observeProcesses Nothing
observeProcesses :: Maybe ProcessID -> IO (Either String [Identity])
observeProcesses target
| os `notElem` ["darwin", "linux"] = pure $ Left "Process inspection has not been validated on this platform"
| otherwise =
( do
environment <- getEnvironment
let fields = "pid=,ppid=,uid=,stat=,lstart=,comm="
selection = case target of
Nothing -> ["-ww", "-axo", fields]
Just pid -> ["-ww", "-p", show pid, "-o", fields]
cp =
(proc psExecutable selection)
{ env = Just (("LC_ALL", "C") : ("TZ", "UTC") : filter (\(k, _) -> k /= "LC_ALL" && k /= "TZ") environment)
}
(code, output, _) <- readCreateProcessWithExitCode cp ""
if code /= ExitSuccess && not (target /= Nothing && null output)
then pure (Left "ps enumeration failed")
else case traverse parseIdentity (lines output) of
Nothing -> pure $ Left "Unparseable process metadata"
Just entries -> do
uid <- getEffectiveUserID
let relevant = filter (\entry -> not entry.zombie && entry.uid == uid && (isPostgres entry || takeFileName entry.command == "initdb")) entries
directories <-
if null relevant || os == "linux"
then pure []
else do
(cwdCode, cwdOutput, _) <-
readCreateProcessWithExitCode
(proc "/usr/sbin/lsof" ["-a", "-p", intercalate "," (map (show . (\entry -> entry.pid)) relevant), "-d", "cwd", "-Fn"])
""
unless (cwdCode == ExitSuccess || not (null cwdOutput)) $ ioError $ userError "Cannot enumerate PostgreSQL working directories"
pure $ parseDirectories Nothing (lines cwdOutput)
Right <$> traverse (\entry -> addArguments entry {workingDirectory = lookup entry.pid directories}) entries
)
`catch` \(e :: IOException) -> pure $ Left (show e)
where
addArguments ident
| ident.zombie = pure ident
| os == "linux" && (isPostgres ident || takeFileName ident.command == "initdb") = do
uid <- getEffectiveUserID
if ident.uid /= uid
then pure ident
else do
let base = "/proc/" <> show ident.pid
cwd <- readSymbolicLink (base <> "/cwd")
exe <- readSymbolicLink (base <> "/exe")
args <- BS.readFile (base <> "/cmdline")
pure ident {command = exe, arguments = unwords (filter (not . null) $ map BS.unpack $ BS.split '\0' args), workingDirectory = Just cwd}
| takeFileName ident.command `elem` ["postgres", "initdb"] = do
(code, output, _) <-
readCreateProcessWithExitCode
(proc psExecutable ["-ww", "-p", show ident.pid, "-o", "args="])
""
-- A disappearing entry invalidates this snapshot; the next sweep retries.
if code == ExitSuccess && not (null output)
then pure ident {arguments = unlinesTrim output}
else ioError $ userError "Process changed during enumeration"
| otherwise = pure ident
unlinesTrim = reverse . dropWhile (== '\n') . reverse . dropWhile (== ' ')
parseIdentity :: String -> Maybe Identity
parseIdentity line = case words line of
p : pp : user : state : day : month : date : clock : year : rest -> do
pid <- readMaybe p
parent <- readMaybe pp
uid <- readMaybe user
time <- parseTimeM True defaultTimeLocale "%a %b %e %T %Y" (unwords [day, month, date, clock, year]) :: Maybe UTCTime
if null rest
then Nothing
else
pure $
Identity
pid
parent
uid
(floor $ utcTimeToPOSIXSeconds time)
(unwords rest)
""
Nothing
("Z" `isPrefixOf` state)
_ -> Nothing
inspectProcess :: ProcessID -> IO Observation
inspectProcess pid
| pid <= 1 = pure $ Unknown "Invalid PID"
| otherwise = do
snapshot <- observeProcesses (Just pid)
case snapshot of
Left reason -> pure $ Unknown reason
Right entries -> case filter (\entry -> entry.pid == pid) entries of
[entry] -> pure $ if entry.zombie then Gone else Present entry
[] ->
(signalProcess nullSignal pid >> pure (Unknown "Process appeared after enumeration"))
`catch` \(e :: IOException) ->
pure $
if isDoesNotExistError e then Gone else Unknown (show e)
_ -> pure $ Unknown "Duplicate process identity"
isPostgres :: Identity -> Bool
isPostgres ident = takeFileName ident.command == "postgres" || "postgres: " `isPrefixOf` ident.command
parseDirectories :: Maybe ProcessID -> [String] -> [(ProcessID, FilePath)]
parseDirectories _ [] = []
parseDirectories _ (('p' : value) : rest) = parseDirectories (readMaybe value) rest
parseDirectories (Just pid) (('n' : path) : rest) = (pid, path) : parseDirectories (Just pid) rest
parseDirectories pid (_ : rest) = parseDirectories pid rest