Skip to content

Instantly share code, notes, and snippets.

@sogaiu
Created September 4, 2025 07:40
Show Gist options
  • Select an option

  • Save sogaiu/86ab04cb443bd06436ec8ed712bc97c3 to your computer and use it in GitHub Desktop.

Select an option

Save sogaiu/86ab04cb443bd06436ec8ed712bc97c3 to your computer and use it in GitHub Desktop.
from llmII
--[[
cqueues-servicekit - A daemonization API based upon ServiceKit
Original ServiceKit can be found -
https://github.com/LuaDist2/servicekit-posix
License:
Copyright (c) 2012 Aaron B.
Copyright (c) 2019 LLMII <[email protected]>
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
--[[
Why:
The old project was languishing, and the stuff from
http://25thandclement.com/~william/projects/ is top notch.
Since most things I've written utilize cqueues, its pretty much widely
available with all my lua installations. Lunix has some benefits over
the other lua posix modules in that it focuses on thread safety.
As the need arose for a way to daemonize some software I wrote, I decided
to look into how it was done in various languages, and what existed for
lua and ServiceKit almost fit the bill but feeling it could be simplified
a bit, and that some functionality could be added to it, I realized I'd
come close to rewriting it. At that point, made sense to base it off of
code I felt more confident in due to experience with that author's code in
other various situations. This, cqueues-servicekit, is what is arrived
at after having stripped away other dependencies, combined things into a
single file (makes more sense that way so far ), and changed things to use
cqueues and lunix. Hopefully others find this code as useful as I do,
and find the improvements to be nice as well.
]]
--[[
Dependencies:
lunix, cqueues
]]
--[[
Usage:
(require 'servicekit')
{
settings = {
-- all below is optional, if not used, do settings = {}
change_priv = nil, -- if is a table, then privileges will be
-- dropped and used for directory ownership if
-- one is created for chroot purposes
-- Table should be like:
-- { user = 'nobody', group = 'nobody' }
-- and if group is omitted, user will be used
-- for group
daemonize = true, -- default true, for foreground, set to false
umask = nil, -- umask to change to
singleton = false, -- keep from running multiple processes
lock_file = 'proc.lf', -- name of lock_file to use for singleton
directory = '/', -- change to default, otherwise, as specified
chroot = false -- chroots to directory specified by this, or
-- if true, to directory, otherwise, no chroot
},
privileged_init = function() end, -- optional
init = function() end, -- optional
logging_init = function() end, -- optional
start = function() end, -- mandatory
stop = function() end, -- mandatory
destroy = function() end, -- optional
reload = function() end -- optional
}
TODO: Document event sequence!
]]
--[[
Changelog:
5/24/2019 -
Reworked a lot of the code to utilize cqueues and lunix.
]]
--[[
BUGS/FEATURES:
Need to double fork: see - http://code.activestate.com/recipes/278731/ -
for why.
Add umask capability, lock file (singleton process) capability.
Clean up how the events work, make sure to check PID stuff in grandparent.
Update useage
Allow for setting different group?
]]
local cqueues = require 'cqueues'
local signal = require 'cqueues.signal'
local unix = require 'unix'
local sk = {}
do -- settings def
sk.settings = {
change_priv = false,
daemonize = true,
umask = false,
singleton = false,
directory = '/',
chroot = false,
lock_file = 'proc.lf',
}
end -- end settings def
do -- event def
local function tnop() return true end
sk.events = {
privileged_init = tnop, -- optional
init = tnop, -- optional
logging_init = tnop, -- optional
start = tnop, -- mandatory
stop = tnop, -- mandatory
destroy = tnop, -- optional
reload = tnop, -- optional
settings = sk.settings
}
end -- end event def
-- the log basically wraps print and friends, or other provided
-- log functions, wherein, until such is provided, it'll store the
-- log data and output later, and if a fatal error occurs, will
-- dump the errors via print anyway and halt waiting for logging functions
local log = {}
do
local errt, inft, reset, _errf, _inff = {}, {}, false
local function dump_log(fun, t)
local f = type(fun) == 'function' and fun or print
for i, v in pairs(t) do
f(v)
end
end
local function exit(ret)
dump_log(_errf, errt)
os.exit(ret)
end
function log.info(str)
if not (_inff and reset) then
table.insert(inft, str)
else
_inff(str)
end
end
function log.error(str)
if not (_errf and reset) then
table.insert(errt, str)
else
_errt(str)
end
end
function log.fatal(str, ret)
log.error('FATAL: ' .. str)
exit(ret or 1)
end
function log.setup(errf, inff)
_errf, _inff = errf, inff
dump_log(_errf, errt)
dump_log(_inff, inft)
end
function log.reset()
_errf, _inff, reset = nil, nil, true
end
end
-- TODO: Document
local utils = {}
do -- utils defs
function utils.assert(success, str)
if not success then
log.fatal(str)
end
end
function utils.handle_fatal(success, es, ec, str)
utils.assert(success, (str .. ' - errno <%s>, err: <%s>'):format(ec, es))
end
-- change directory
function utils.chdir(directory)
local success, es, ec = unix.chdir(directory)
utils.handle_fatal(
success, es, ec,
('Failed to change to directory: %s'):format(directory)
)
end
utils.finished = false
end -- end utils def
local privileges = {}
do -- privileges def
local user, group
function privileges.init(priv)
if priv then
local uinfo = unix.getpwnam(priv.user)
user, group = uinfo.uid, uinfo.gid
group = priv.group and unix.getpwnam(priv.group).gid or group
end
end
function privileges.setup(priv)
if priv then
local success, es, ec = unix.setegid(group)
utils.handle_fatal(
success, es, ec,
('Failed to drop group privileges to %s'):format
(
priv.group or priv.user
)
)
local success, es, ec = unix.seteuid(user)
utils.handle_fatal(
success, es, ec,
('Failed to drop user privileges to %s'):format(priv.user)
)
log.info(
('Dropped priviledges to <%s:%s>.'):format
(
priv.user, priv.group or priv.user
)
)
end
end
end -- end privileges def
-- almost all functions here are designed to exit the process in the
-- case of failure as it wouldn't result in a properly setup daemon if done
-- incorrectly
local daemonize = {}
do -- daemonize def
local null
-- fork a child, exit the parent
local function fork_and_suicide()
local pid, es, ec = unix.fork()
utils.handle_fatal(pid, es, ec, 'Failed to fork daemon')
if pid > 0 then
os.exit()
end
return pid
end
-- sets umask for the process
local function umask(mask)
success, es, ec = unix.umask(mask or unix.umask())
utils.handle_fatal(
success, es, ec,
('Failed to set umask: %s'):format(mask or unix.umask())
)
end
-- opens /dev/null
local function open_null()
local null, es, ec = unix.open('/dev/null')
utils.handle_fatal(null, es, ec, 'Failed to open /dev/null')
return null
end
-- assign the FD in file1 to the FD in file2
local function redirect(file1, file2)
local success, es, ec = unix.dup2(file1, file2)
utils.handle_fatal(
success, es, ec,
('Cannot dup %s to %s'):format(file1, file2)
)
end
-- reassign stdio to null for daemonization purposes explained in _daemonize
local function reset_stdio()
redirect(null, 0)
redirect(null, 1)
redirect(null, 2)
end
-- detach the process from the controlling terminal and run it in the
-- background as a daemon
local function full_detach()
-- Fork a child process returning control to the parent (likely a
-- shell or terminal). Required to ensure a call to setsid is
-- successful
fork_and_suicide()
-- Setsid to make this process the session leader and process group
-- leader ensuring there is no controlling terminal.
local success, es, ec = unix.setsid()
utils.handle_fatal(success, es, ec, 'Failed to daemonize.')
-- Fork to create grand-child, the parent of which exits immediately
-- preventing zombie processes. With the grand-child orphaned, the process
-- becomes owned by init, making it responsible for cleanup. The parent
-- was capable of acquiring a controlling terminal by opening one, but
-- this final fork guarantees this process is no longer a session leader,
-- and is incapable of acquiring a controlling terminal.
fork_and_suicide()
end
local function _daemonize(directory, mask)
-- become a daemon
-- Notes: maybe we should close all other file descriptors? probably
-- would ruin the point of acquiring a socket with low port number or
-- other root privileged resources however.
full_detach()
-- change directory, avoid disallowing dismounts by holding open
-- a working directory outside of '/', unless directory is otherwise
-- specified, in which case it could be a preperation for chroot or
-- orchestrated differently and hopefully correctly by the caller
utils.chdir(directory)
-- change to the umask specified, or set the current umask as the umask,
-- giving the daemon complete control over file permissions at file
-- creation.
umask(mask or unix.umask())
-- prevent stray resource molestation and odd side effects caused by
-- writing/reading stdio by remapping all of stdio to /dev/null
reset_stdio()
end
-- use a servicekit events object to get daemonization settings, will
-- only daemonize if the settings call for daemonization, otherwise silently
-- do nothing
function daemonize.init(events)
if events.settings.daemonize == true then
null = open_null()
end
end
function daemonize.setup(events)
if events.settings.daemonize == true then
log.info 'Daemonization in progress.'
_daemonize(events.settings.directory, events.settings.umask)
log.info 'Daemonized.'
end
end
end -- end daemonize def
-- TODO: Document
local signals = {}
do -- signals def
local handlers = {
[signal.SIGINT] = 'stop',
[signal.SIGTERM] = 'stop',
[signal.SIGHUP] = 'reload',
default = function() end
}
-- block and handle signals relevant to a daemon.
function signals.setup(cq, events)
log.info 'Setting up signal handlers.'
local cq = cqueues.new()
-- block all signals we care about
signal.block(signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
-- listen for the signals
local sl = signal.listen(signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
-- install a function to handle the signals
cq:wrap(
function()
-- if we're finished, signals don't matter any more
while not utils.finished do
-- dispatch signal to the correct handler
local call = handlers[sl:wait()] or handlers.default
events[call]()
end
end
)
return cq
end
end -- end signals def
local chroot = {}
do -- chroot def
-- check settings and determine if to chroot, and where to chroot to
local function init(events)
local directory
if events.settings.chroot then
if events.settings.chroot == true then
directory = events.settings.directory
else
directory = events.settings.chroot
end
end
if directory then
local success, es, ec = unix.mkdir(directory)
if not success and es ~= 'File exists' then
utils.handle_fatal(
success, es, ec,
('Failed to setup chroot directory - %s'):format(directory)
)
end
success, es, ec = unix.chown(
directory,
events.settings.change_priv.user,
events.settings.change_priv.group
)
utils.handle_fatal(
success, es, ec,
('Failed to change owner to %s:%s'):format
(
events.settings.change_priv.user,
events.settings.change_priv.group
)
)
end
return directory
end
-- if specified to chroot, create directory if it doesn't exist, and chroot
-- to it
function chroot.setup(events)
local directory = init(events)
if directory then
events.settings.directory = '/'
log.info(('Chrooting to <%s>.'):format(directory))
local success, es, ec = unix.chroot(directory)
utils.handle_fatal(
success, es, ec,
('Failed to chroot to directory - %s'):format(directory)
)
utils.chdir('/')
end
end
end -- end chroot def
local singleton = {}
do -- singleton def
-- try to get a lock on the lock file, otherwise commit suicide after
-- informing the user that there can't be 2 of this process and they were
-- there first
function singleton.setup(events)
if events.settings.singleton == true then
local lf, es, ec = io.open(events.settings.lock_file, 'w')
local success, es, ec = unix.fcntl(lf, unix.F_SETLK)
if not success then
local pid = unix.fcntl(lf, unix.F_GETLK).pid
utils.handle_fatal(
success, es, ec,
('Failed to start service, service already running, PID: %s'):format
(
pid
)
)
end
end
end
end -- end singleton def
do -- servicekit internals
-- checks the settings and the events provided by the user to make sure
-- that only the settings or events specified are provisioned
local function setup(config)
assert(type(config) == 'table')
for k, v in pairs(config.settings) do
assert(sk.settings[k] ~= nil, ('Unknown setting: %s'):format(k))
sk.settings[k] = v
end
config.settings = sk.settings
for k, v in pairs(config) do
assert(sk.events[k] ~= nil, ('Unknown event: %s'):format(k))
sk.events[k] = v
end
return sk.events
end
function sk.run(settings)
local events, cq = setup(settings)
utils.assert(events, 'Service is undefined.')
-- pre-init, all stuff before dropping privileges
events.privileged_init()
-- these need some things figured out before a chroot takes place
privileges.init(events.settings.change_priv)
daemonize.init(events)
chroot.setup(events) -- chroot, must be done as root
-- drop privileges if we're going to!
privileges.setup(events.settings.change_priv)
-- make sure we're the only one if we're supposed to be the only one
-- first check here, then after daemonization grab the actual lock,
-- this allows for a printing of a message that its already running
-- the grandparent will only hold the lock for a moment until the
-- grandchild exists as it will exit
singleton.setup(events)
-- daemonize
-- past here, failures can be silent!
daemonize.setup(events)
-- grab the lock if needed
singleton.setup(events)
-- afix signal handlers for the daemon
cq = signals.setup(cq, events)
-- pass control over to daemon code
events.init()
local le, li = events.logging_init()
log.setup(le, li)
-- rest is async, so signal handlers can get called
cq:wrap(
function()
events.start()
events.destroy()
utils.finished = true
end
)
-- error string, error code, error thread, error object, error fd
-- in the case of error, we doubt that the main loop deinitialized logging
-- and assume we can use the logging functions it provided before exiting.
local success, es, ec, et, eo, ef = cq:loop()
if not success then
log.fatal(
('error: %s\ncode: %s\nthread: %s\nobject: %s\nfd: %s\n'):format
(
success, es, ec, et, eo, ef
)
)
end
end
end -- end servicekit internals
return sk.run
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment