]> gitweb.michael.orlitzky.com - dead/htsn.git/commitdiff
Add a bunch of new options allowing htsn to daemonize.
authorMichael Orlitzky <michael@orlitzky.com>
Mon, 23 Dec 2013 01:48:31 +0000 (20:48 -0500)
committerMichael Orlitzky <michael@orlitzky.com>
Mon, 23 Dec 2013 01:48:31 +0000 (20:48 -0500)
doc/htsnrc.example
htsn.cabal
src/CommandLine.hs
src/Configuration.hs
src/ExitCodes.hs
src/Main.hs
src/OptionalConfiguration.hs
src/Unix.hs [new file with mode: 0644]

index 2158504374e5ea5885106009311b1b18db94316c..f82e19586853272c02e3dfc91288a48a96d8a889 100644 (file)
@@ -3,27 +3,11 @@
 # needs to go in %APPDATA%, or C:\Users\<username>\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
 # 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
 # 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"
index 8aa384ff1de925b0b61d3a36b0c760de69c42cf1..a59f2df0d337fb11f63285d4efa851ad04e7bef5 100644 (file)
@@ -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.*,
index c96fb6b568018f4eeb3462e4e8f42ab0552d8e54..b734d770b5dde0bcc9c6309254a746aaa60d5eda 100644 (file)
@@ -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]
index a90af38435cbcd3df1fefd767f401203a4bfa169..68751d6015784242e7ecdb89fcf2ee568ef383da 100644 (file)
@@ -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
index b5765cc58311ed12c54d3069838e9bc3924c6f1b..f412a07b040a38a7d0c5bf529975f5bdb44746df 100644 (file)
@@ -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
index 4ebada07e858fb53d2ca983503ced14201a1fd22..b6403614d5d46af193e1f7b2b2adef5ccc8c4bcd 100644 (file)
@@ -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
index 0913864e0bb064cfd61884e25940a1e326eda771..69ea04176583956f4bac6cf9ce89637bdc7806b7 100644 (file)
@@ -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 (file)
index 0000000..8931326
--- /dev/null
@@ -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