From f6cb0ba712e06e52d080b86e9eba6c3585a7514b Mon Sep 17 00:00:00 2001 From: Michael Orlitzky Date: Sun, 22 Dec 2013 20:48:31 -0500 Subject: [PATCH] Add a bunch of new options allowing htsn to daemonize. --- doc/htsnrc.example | 83 ++++++++++++++++++++++++------------ htsn.cabal | 2 + src/CommandLine.hs | 40 ++++++++++++----- src/Configuration.hs | 25 +++++++++-- src/ExitCodes.hs | 9 +++- src/Main.hs | 33 ++++++++------ src/OptionalConfiguration.hs | 31 +++++++++++--- src/Unix.hs | 66 ++++++++++++++++++++++++++++ 8 files changed, 228 insertions(+), 61 deletions(-) create mode 100644 src/Unix.hs diff --git a/doc/htsnrc.example b/doc/htsnrc.example index 2158504..f82e195 100644 --- a/doc/htsnrc.example +++ b/doc/htsnrc.example @@ -3,27 +3,11 @@ # needs to go in %APPDATA%, or C:\Users\\Application Data. -# The username used to connect to the feed. -# -# Default: none (required) -# -# username = "whoever" - - -# The password associated with your TSN username. -# -# Default: none (required) +# Run in the background as a daemon? # -# password = "whatever" - - -# By default, XML files will be written to the current working -# directory. Often this is not desirable, and you would rather save -# them to a specific location. Specify it here. -# -# Default: "." +# Default: false # -# output-directory = "/var/lib/htsn" +# daemonize = True # A list of hostnames that supply the feed. You probably don't need to @@ -36,15 +20,6 @@ # feed-hosts = [ "hostname1", "hostname2", ... ] -# Do you want to log to syslog? On Windows this will attempt to -# communicate (over UDP) with a syslog daemon on localhost, which will -# most likely not work. -# -# Default: False -# -# syslog = True - - # If you specify a file path here, logs will be written to it # (possibly in addition to syslog). Can be either a relative or # absolute path. It will not be auto-rotated; use something like @@ -64,3 +39,55 @@ # Default: "INFO" # # log_level = "WARNING" + + +# By default, XML files will be written to the current working +# directory. Often this is not desirable, and you would rather save +# them to a specific location. Specify it here. +# +# Default: "." +# +# output-directory = "/var/lib/htsn" + + +# The password associated with your TSN username. +# +# Default: none (required) +# +# password = "whatever" + + +# (Daemon mode only) Create a PID file in the given location. +# +# Default: /run/htsn.pid +# +# pidfile = /var/run/htsn.pid + + +# (Daemon mode only) Run htsn as the specified system grup. +# +# Default: the current group +# +# run-as-group = htsn + + +# (Daemon mode only) Run htsn as the specified system user. +# +# Default: the current user +# +# run-as-user = htsn + +# Do you want to log to syslog? On Windows this will attempt to +# communicate (over UDP) with a syslog daemon on localhost, which will +# most likely not work. +# +# Default: False +# +# syslog = True + + +# The username used to connect to the feed. +# +# Default: none (required) +# +# username = "whoever" diff --git a/htsn.cabal b/htsn.cabal index 8aa384f..a59f2df 100644 --- a/htsn.cabal +++ b/htsn.cabal @@ -22,6 +22,7 @@ executable htsn configurator == 0.2.*, directory == 1.2.*, filepath == 1.3.*, + hdaemonize == 0.4.*, hslogger == 1.2.*, hxt == 9.3.*, MissingH == 1.2.*, @@ -72,6 +73,7 @@ test-suite testsuite configurator == 0.2.*, directory == 1.2.*, filepath == 1.3.*, + hdaemonize == 0.4.*, hslogger == 1.2.*, hxt == 9.3.*, MissingH == 1.2.*, diff --git a/src/CommandLine.hs b/src/CommandLine.hs index c96fb6b..b734d77 100644 --- a/src/CommandLine.hs +++ b/src/CommandLine.hs @@ -36,6 +36,10 @@ my_summary :: String my_summary = program_name ++ "-" ++ (showVersion version) +daemonize_help :: String +daemonize_help = + "Run as a daemon, in the background." + -- | A description of the "log_file" option. log_file_help :: String log_file_help = @@ -45,15 +49,27 @@ log_level_help :: String log_level_help = "How verbose should the logs be? One of INFO, WARNING, ERROR." +-- | A description of the "output_directory" option. +output_directory_help :: String +output_directory_help = + "Directory in which to output the XML files; must be writable" + -- | A description of the "password" option. password_help :: String password_help = "Password to use when connecting to the feed" --- | A description of the "output_directory" option. -output_directory_help :: String -output_directory_help = - "Directory in which to output the XML files; must be writable" +pidfile_help :: String +pidfile_help = + "Location to create PID file (daemon only)." + +run_as_group_help :: String +run_as_group_help = + "System group to run as (daemon only)." + +run_as_user_help :: String +run_as_user_help = + "System user to run under (daemon only)." -- | A description of the "syslog" option. syslog_help :: String @@ -74,13 +90,17 @@ arg_spec = -- Use an empty list for feed_hosts since cmdargs will appends to -- the default when the user supplies feed hosts. If he specifies -- any, those are all we should use. + daemonize = def &= typ "BOOL" &= help daemonize_help, feed_hosts = def &= typ "HOSTNAMES" &= args, - log_file = def &= typFile &= help log_file_help, - log_level = def &= typ "LEVEL" &= help log_level_help, - password = def &= typ "PASSWORD" &= help password_help, - output_directory = def &= typDir &= help output_directory_help, - syslog = def &= typ "BOOL" &= help syslog_help, - username = def &= typ "USERNAME" &= help username_help } + log_file = def &= typFile &= help log_file_help, + log_level = def &= typ "LEVEL" &= help log_level_help, + output_directory = def &= typDir &= help output_directory_help, + password = def &= typ "PASSWORD" &= help password_help, + pidfile = def &= typFile &= help pidfile_help, + run_as_group = def &= typ "GROUP" &= help run_as_group_help, + run_as_user = def &= typ "USER" &= help run_as_user_help, + syslog = def &= typ "BOOL" &= help syslog_help, + username = def &= typ "USERNAME" &= help username_help } &= program program_name &= summary my_summary &= details [description] diff --git a/src/Configuration.hs b/src/Configuration.hs index a90af38..68751d6 100644 --- a/src/Configuration.hs +++ b/src/Configuration.hs @@ -17,11 +17,15 @@ import TSN.FeedHosts (FeedHosts(..)) data Configuration = Configuration { + daemonize :: Bool, feed_hosts :: FeedHosts, log_file :: Maybe FilePath, log_level :: Priority, - password :: String, output_directory :: FilePath, + password :: String, + pidfile :: FilePath, + run_as_group :: Maybe String, + run_as_user :: Maybe String, syslog :: Bool, username :: String } deriving (Show) @@ -29,7 +33,18 @@ data Configuration = -- | A Configuration with all of its fields set to their default -- values. instance Default Configuration where - def = Configuration def def INFO def "." def def + def = Configuration { + daemonize = def, + feed_hosts = def, + log_file = def, + log_level = INFO, + output_directory = ".", + password = def, + pidfile = "/run/htsn.pid", + run_as_group = def, + run_as_user = def, + syslog = def, + username = def } -- | Merge a Configuration with an OptionalConfiguration. This is more @@ -40,11 +55,15 @@ merge_optional :: Configuration -> Configuration merge_optional cfg opt_cfg = Configuration + (merge (daemonize cfg) (OC.daemonize opt_cfg)) all_feed_hosts (OC.merge_maybes (log_file cfg) (OC.log_file opt_cfg)) (merge (log_level cfg) (OC.log_level opt_cfg)) - (merge (password cfg) (OC.password opt_cfg)) (merge (output_directory cfg) (OC.output_directory opt_cfg)) + (merge (password cfg) (OC.password opt_cfg)) + (merge (pidfile cfg) (OC.pidfile opt_cfg)) + (OC.merge_maybes (run_as_group cfg) (OC.run_as_group opt_cfg)) + (OC.merge_maybes (run_as_user cfg) (OC.run_as_user opt_cfg)) (merge (syslog cfg) (OC.syslog opt_cfg)) (merge (username cfg) (OC.username opt_cfg)) where diff --git a/src/ExitCodes.hs b/src/ExitCodes.hs index b5765cc..f412a07 100644 --- a/src/ExitCodes.hs +++ b/src/ExitCodes.hs @@ -4,7 +4,8 @@ module ExitCodes ( exit_no_feed_hosts, exit_no_password, - exit_no_username ) + exit_no_username, + exit_pidfile_exists ) where -- | No feed hosts were given on the command line or in the config file. @@ -18,3 +19,9 @@ exit_no_password = 2 -- | No username was given on the command line or in the config file. exit_no_username :: Int exit_no_username = 3 + +-- | When running as a daemon, the existence of a fixed PID file is +-- used to determine whether or not the daemon is already +-- running. If the PID file already exists, we shouldn't start. +exit_pidfile_exists :: Int +exit_pidfile_exists = 4 diff --git a/src/Main.hs b/src/Main.hs index 4ebada0..b640361 100644 --- a/src/Main.hs +++ b/src/Main.hs @@ -35,7 +35,8 @@ import Configuration ( Configuration(..), merge_optional ) import ExitCodes ( exit_no_feed_hosts, exit_no_password, - exit_no_username ) + exit_no_username, + exit_pidfile_exists ) import Logging ( init_logging, log_debug, @@ -53,7 +54,7 @@ import Terminal ( display_warning ) import TSN.FeedHosts ( FeedHosts(..) ) import TSN.Xml ( parse_xmlfid ) - +import Unix ( full_daemonize ) -- | Display and log debug information. WARNING! This does not -- automatically append a newline. The output is displayed/logged @@ -81,15 +82,6 @@ report_info s = do log_info s --- | A special case of report_debug for reporting the two bits of data --- that we sent to TSN: the username and password. --- -report_sent :: String -> IO () -report_sent s = do - display_sent s - log_debug s - - -- | Display and log a warning. This will prefix the warning with -- "WARNING: " when displaying (but not logging) it so that it -- stands out. @@ -191,6 +183,7 @@ log_in cfg h = do send_line h' s = do let line = s ++ "\r\n" hPutStr h' line + -- Don't log the username/password! display_sent line recv_chars :: Int -> Handle -> IO String @@ -282,13 +275,27 @@ main = do report_error "No username supplied." exitWith (ExitFailure exit_no_username) + when (daemonize cfg) $ do + pidfile_exists <- doesFileExist (pidfile cfg) + when pidfile_exists $ do + report_error $ "PID file " ++ (pidfile cfg) ++ " already exists. " + ++ "Refusing to start." + exitWith (ExitFailure exit_pidfile_exists) + -- This may be superstition (and I believe stderr is unbuffered), -- but it can't hurt. hSetBuffering stderr NoBuffering hSetBuffering stdout NoBuffering - -- Begin connecting to our feed hosts, starting with the first one. - round_robin cfg 0 + -- The rest of the program is kicked off by the following line which + -- begins connecting to our feed hosts, starting with the first one, + -- and proceeds in a round-robin fashion. + let run_program = round_robin cfg 0 + + -- If we were asked to daemonize, do that; otherwise just run the thing. + if (daemonize cfg) + then full_daemonize cfg run_program + else run_program where -- | This is the top-level "loop forever" function. If an diff --git a/src/OptionalConfiguration.hs b/src/OptionalConfiguration.hs index 0913864..69ea041 100644 --- a/src/OptionalConfiguration.hs +++ b/src/OptionalConfiguration.hs @@ -50,11 +50,15 @@ deriving instance Typeable Priority -- data OptionalConfiguration = OptionalConfiguration { + daemonize :: Maybe Bool, feed_hosts :: FeedHosts, log_file :: Maybe FilePath, log_level :: Maybe Priority, - password :: Maybe String, output_directory :: Maybe FilePath, + password :: Maybe String, + pidfile :: Maybe FilePath, + run_as_group :: Maybe String, + run_as_user :: Maybe String, syslog :: Maybe Bool, username :: Maybe String } deriving (Show, Data, Typeable) @@ -81,6 +85,7 @@ merge_maybes (Just _) (Just y) = Just y instance Monoid OptionalConfiguration where -- | An empty OptionalConfiguration. mempty = OptionalConfiguration + Nothing (FeedHosts []) Nothing Nothing @@ -88,16 +93,23 @@ instance Monoid OptionalConfiguration where Nothing Nothing Nothing + Nothing + Nothing + Nothing -- | Combine @cfg1@ and @cfg2@, giving precedence to @cfg2@. cfg1 `mappend` cfg2 = OptionalConfiguration + (merge_maybes (daemonize cfg1) (daemonize cfg2)) all_feed_hosts (merge_maybes (log_file cfg1) (log_file cfg2)) (merge_maybes (log_level cfg1) (log_level cfg2)) - (merge_maybes (password cfg1) (password cfg2)) (merge_maybes (output_directory cfg1) (output_directory cfg2)) + (merge_maybes (password cfg1) (password cfg2)) + (merge_maybes (pidfile cfg1) (pidfile cfg2)) + (merge_maybes (run_as_group cfg1) (run_as_group cfg2)) + (merge_maybes (run_as_user cfg1) (run_as_user cfg2)) (merge_maybes (syslog cfg1) (syslog cfg2)) (merge_maybes (username cfg1) (username cfg2)) where @@ -139,20 +151,27 @@ from_rc = do return "$(HOME)") let user_config_path = home ".htsnrc" cfg <- DC.load [ DC.Optional user_config_path ] + cfg_daemonize <- DC.lookup cfg "daemonize" + cfg_feed_hosts <- DC.lookup cfg "feed_hosts" cfg_log_file <- DC.lookup cfg "log_file" cfg_log_level <- DC.lookup cfg "log_level" - cfg_password <- DC.lookup cfg "password" cfg_output_directory <- DC.lookup cfg "output_directory" + cfg_password <- DC.lookup cfg "password" + cfg_pidfile <- DC.lookup cfg "pidfile" + cfg_run_as_group <- DC.lookup cfg "run_as_group" + cfg_run_as_user <- DC.lookup cfg "run_as_user" cfg_syslog <- DC.lookup cfg "syslog" cfg_username <- DC.lookup cfg "username" - cfg_feed_hosts <- DC.lookup cfg "feed_hosts" return $ OptionalConfiguration + cfg_daemonize (fromMaybe (FeedHosts []) cfg_feed_hosts) cfg_log_file cfg_log_level - cfg_password cfg_output_directory + cfg_password + cfg_pidfile + cfg_run_as_group + cfg_run_as_user cfg_syslog cfg_username - diff --git a/src/Unix.hs b/src/Unix.hs new file mode 100644 index 0000000..8931326 --- /dev/null +++ b/src/Unix.hs @@ -0,0 +1,66 @@ +module Unix +where + +import Control.Concurrent ( ThreadId, myThreadId ) +import Control.Exception ( throwTo ) +import System.Exit ( ExitCode( ExitSuccess ) ) +import System.Posix ( + GroupEntry ( groupID ), + GroupID, + Handler ( Catch ), + UserEntry ( userID ), + UserID, + getGroupEntryForName, + getProcessID, + getRealGroupID, + getRealUserID, + getUserEntryForName, + installHandler, + removeLink, + setGroupID, + setUserID, + sigTERM ) +import System.Posix.Daemonize ( daemonize ) + +import Configuration ( + Configuration( pidfile, + run_as_group, + run_as_user )) +import Logging ( log_info ) + +get_user_id :: Maybe String -> IO UserID +get_user_id Nothing = getRealUserID +get_user_id (Just s) = fmap userID (getUserEntryForName s) + +get_group_id :: Maybe String -> IO GroupID +get_group_id Nothing = getRealGroupID +get_group_id (Just s) = fmap groupID (getGroupEntryForName s) + +graceful_shutdown :: Configuration -> ThreadId -> IO () +graceful_shutdown cfg main_thread_id = do + log_info "SIGTERM received, removing PID file and shutting down." + removeLink (pidfile cfg) + throwTo main_thread_id ExitSuccess + +full_daemonize :: Configuration -> IO () -> IO () +full_daemonize cfg program = do + -- This is the 'daemonize' from System.Posix.Daemonize. + daemonize program' + where + -- We need to do all this stuff *after* we daemonize. + program' = do + -- First write the PID file which probably requires root. + pid <- getProcessID + writeFile (pidfile cfg) (show pid) + + -- We need to pass the thread ID to the signal handler so it + -- knows which process to "exit." + tid <- myThreadId + _ <- installHandler sigTERM (Catch (graceful_shutdown cfg tid)) Nothing + + -- Then drop privileges. + get_user_id (run_as_user cfg) >>= setUserID + get_group_id (run_as_group cfg) >>= setGroupID + + -- Finally run the program we were asked to. + program -- 2.44.2