aboutsummaryrefslogtreecommitdiff
path: root/libexec/nuageinit/nuage.lua
diff options
context:
space:
mode:
Diffstat (limited to 'libexec/nuageinit/nuage.lua')
-rw-r--r--libexec/nuageinit/nuage.lua710
1 files changed, 710 insertions, 0 deletions
diff --git a/libexec/nuageinit/nuage.lua b/libexec/nuageinit/nuage.lua
new file mode 100644
index 000000000000..3eeb2ea0b44c
--- /dev/null
+++ b/libexec/nuageinit/nuage.lua
@@ -0,0 +1,710 @@
+---
+-- SPDX-License-Identifier: BSD-2-Clause
+--
+-- Copyright(c) 2022-2025 Baptiste Daroussin <bapt@FreeBSD.org>
+-- Copyright(c) 2025 Jesús Daniel Colmenares Oviedo <dtxdf@FreeBSD.org>
+
+local unistd = require("posix.unistd")
+local sys_stat = require("posix.sys.stat")
+local lfs = require("lfs")
+
+local function getlocalbase()
+ local f = io.popen("sysctl -in user.localbase 2> /dev/null")
+ local localbase = f:read("*l")
+ f:close()
+ if localbase == nil or localbase:len() == 0 then
+ -- fallback
+ localbase = "/usr/local"
+ end
+ return localbase
+end
+
+local function decode_base64(input)
+ local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
+ input = string.gsub(input, '[^'..b..'=]', '')
+
+ local result = {}
+ local bits = ''
+
+ -- convert all characters in bits
+ for i = 1, #input do
+ local x = input:sub(i, i)
+ if x == '=' then
+ break
+ end
+ local f = b:find(x) - 1
+ for j = 6, 1, -1 do
+ bits = bits .. (f % 2^j - f % 2^(j-1) > 0 and '1' or '0')
+ end
+ end
+
+ for i = 1, #bits, 8 do
+ local byte = bits:sub(i, i + 7)
+ if #byte == 8 then
+ local c = 0
+ for j = 1, 8 do
+ c = c + (byte:sub(j, j) == '1' and 2^(8 - j) or 0)
+ end
+ table.insert(result, string.char(c))
+ end
+ end
+
+ return table.concat(result)
+end
+
+local function warnmsg(str, prepend)
+ if not str then
+ return
+ end
+ local tag = ""
+ if prepend ~= false then
+ tag = "nuageinit: "
+ end
+ io.stderr:write(tag .. str .. "\n")
+end
+
+local function errmsg(str, prepend)
+ warnmsg(str, prepend)
+ os.exit(1)
+end
+
+local function chmod(path, mode)
+ local mode = tonumber(mode, 8)
+ local _, err, msg = sys_stat.chmod(path, mode)
+ if err then
+ errmsg("chmod(" .. path .. ", " .. mode .. ") failed: " .. msg)
+ end
+end
+
+local function chown(path, owner, group)
+ local _, err, msg = unistd.chown(path, owner, group)
+ if err then
+ errmsg("chown(" .. path .. ", " .. owner .. ", " .. group .. ") failed: " .. msg)
+ end
+end
+
+local function dirname(oldpath)
+ if not oldpath then
+ return nil
+ end
+ local path = oldpath:gsub("[^/]+/*$", "")
+ if path == "" then
+ return nil
+ end
+ return path
+end
+
+local function mkdir_p(path)
+ if lfs.attributes(path, "mode") ~= nil then
+ return true
+ end
+ local r, err = mkdir_p(dirname(path))
+ if not r then
+ return nil, err .. " (creating " .. path .. ")"
+ end
+ return lfs.mkdir(path)
+end
+
+local function sethostname(hostname)
+ if hostname == nil then
+ return
+ end
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ if not root then
+ root = ""
+ end
+ local hostnamepath = root .. "/etc/rc.conf.d/hostname"
+
+ mkdir_p(dirname(hostnamepath))
+ local f, err = io.open(hostnamepath, "w")
+ if not f then
+ warnmsg("Impossible to open " .. hostnamepath .. ":" .. err)
+ return
+ end
+ f:write('hostname="' .. hostname .. '"\n')
+ f:close()
+end
+
+local function splitlist(list)
+ local ret = {}
+ if type(list) == "string" then
+ for str in list:gmatch("([^, ]+)") do
+ ret[#ret + 1] = str
+ end
+ elseif type(list) == "table" then
+ ret = list
+ else
+ warnmsg("Invalid type " .. type(list) .. ", expecting table or string")
+ end
+ return ret
+end
+
+local function splitlines(s)
+ local ret = {}
+
+ for line in string.gmatch(s, "[^\n]+") do
+ ret[#ret + 1] = line
+ end
+
+ return ret
+end
+
+local function getgroups()
+ local ret = {}
+
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ local cmd = "pw "
+ if root then
+ cmd = cmd .. "-R " .. root .. " "
+ end
+
+ local f = io.popen(cmd .. "groupshow -a 2> /dev/null | cut -d: -f1")
+ local groups = f:read("*a")
+ f:close()
+
+ return splitlines(groups)
+end
+
+local function checkgroup(group)
+ local groups = getgroups()
+
+ for _, group2chk in ipairs(groups) do
+ if group == group2chk then
+ return true
+ end
+ end
+
+ return false
+end
+
+local function purge_group(groups)
+ local ret = {}
+
+ for _, group in ipairs(groups) do
+ if checkgroup(group) then
+ ret[#ret + 1] = group
+ else
+ warnmsg("ignoring non-existent group '" .. group .. "'")
+ end
+ end
+
+ return ret
+end
+
+local function adduser(pwd)
+ if (type(pwd) ~= "table") then
+ warnmsg("Argument should be a table")
+ return nil
+ end
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ local cmd = "pw "
+ if root then
+ cmd = cmd .. "-R " .. root .. " "
+ end
+ local f = io.popen(cmd .. " usershow " .. pwd.name .. " -7 2> /dev/null")
+ local pwdstr = f:read("*a")
+ f:close()
+ if pwdstr:len() ~= 0 then
+ return pwdstr:match("%a+:.+:%d+:%d+:.*:(.*):.*")
+ end
+ if not pwd.gecos then
+ pwd.gecos = pwd.name .. " User"
+ end
+ if not pwd.homedir then
+ pwd.homedir = "/home/" .. pwd.name
+ end
+ local extraargs = ""
+ if pwd.groups then
+ local list = splitlist(pwd.groups)
+ -- pw complains if the group does not exist, so if the user
+ -- specifies one that cannot be found, nuageinit will generate
+ -- an exception and exit, unlike cloud-init, which only issues
+ -- a warning but creates the user anyway.
+ list = purge_group(list)
+ if #list > 0 then
+ extraargs = " -G " .. table.concat(list, ",")
+ end
+ end
+ -- pw will automatically create a group named after the username
+ -- do not add a -g option in this case
+ if pwd.primary_group and pwd.primary_group ~= pwd.name then
+ extraargs = extraargs .. " -g " .. pwd.primary_group
+ end
+ if not pwd.no_create_home then
+ extraargs = extraargs .. " -m "
+ end
+ if not pwd.shell then
+ pwd.shell = "/bin/sh"
+ end
+ local precmd = ""
+ local postcmd = ""
+ local input = nil
+ if pwd.passwd then
+ input = pwd.passwd
+ postcmd = " -H 0"
+ elseif pwd.plain_text_passwd then
+ input = pwd.plain_text_passwd
+ postcmd = " -h 0"
+ end
+ cmd = precmd .. "pw "
+ if root then
+ cmd = cmd .. "-R " .. root .. " "
+ end
+ cmd = cmd .. "useradd -n " .. pwd.name .. " -M 0755 -w none "
+ cmd = cmd .. extraargs .. " -c '" .. pwd.gecos
+ cmd = cmd .. "' -d '" .. pwd.homedir .. "' -s " .. pwd.shell .. postcmd
+
+ f = io.popen(cmd, "w")
+ if input then
+ f:write(input)
+ end
+ local r = f:close(cmd)
+ if not r then
+ warnmsg("fail to add user " .. pwd.name)
+ warnmsg(cmd)
+ return nil
+ end
+ if pwd.locked then
+ cmd = "pw "
+ if root then
+ cmd = cmd .. "-R " .. root .. " "
+ end
+ cmd = cmd .. "lock " .. pwd.name
+ os.execute(cmd)
+ end
+ return pwd.homedir
+end
+
+local function addgroup(grp)
+ if (type(grp) ~= "table") then
+ warnmsg("Argument should be a table")
+ return false
+ end
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ local cmd = "pw "
+ if root then
+ cmd = cmd .. "-R " .. root .. " "
+ end
+ local f = io.popen(cmd .. " groupshow " .. grp.name .. " 2> /dev/null")
+ local grpstr = f:read("*a")
+ f:close()
+ if grpstr:len() ~= 0 then
+ return true
+ end
+ local extraargs = ""
+ if grp.members then
+ local list = splitlist(grp.members)
+ extraargs = " -M " .. table.concat(list, ",")
+ end
+ cmd = "pw "
+ if root then
+ cmd = cmd .. "-R " .. root .. " "
+ end
+ cmd = cmd .. "groupadd -n " .. grp.name .. extraargs
+ local r = os.execute(cmd)
+ if not r then
+ warnmsg("fail to add group " .. grp.name)
+ warnmsg(cmd)
+ return false
+ end
+ return true
+end
+
+local function addsshkey(homedir, key)
+ local chownak = false
+ local chowndotssh = false
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ if root then
+ homedir = root .. "/" .. homedir
+ end
+ local ak_path = homedir .. "/.ssh/authorized_keys"
+ local dotssh_path = homedir .. "/.ssh"
+ local dirattrs = lfs.attributes(ak_path)
+ if dirattrs == nil then
+ chownak = true
+ dirattrs = lfs.attributes(dotssh_path)
+ if dirattrs == nil then
+ assert(lfs.mkdir(dotssh_path))
+ chowndotssh = true
+ dirattrs = lfs.attributes(homedir)
+ end
+ end
+
+ local f = io.open(ak_path, "a")
+ if not f then
+ warnmsg("impossible to open " .. ak_path)
+ return
+ end
+ f:write(key .. "\n")
+ f:close()
+ if chownak then
+ chmod(ak_path, "0600")
+ chown(ak_path, dirattrs.uid, dirattrs.gid)
+ end
+ if chowndotssh then
+ chmod(dotssh_path, "0700")
+ chown(dotssh_path, dirattrs.uid, dirattrs.gid)
+ end
+end
+
+local function adddoas(pwd)
+ local chmodetcdir = false
+ local chmoddoasconf = false
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ local localbase = getlocalbase()
+ local etcdir = localbase .. "/etc"
+ if root then
+ etcdir= root .. etcdir
+ end
+ local doasconf = etcdir .. "/doas.conf"
+ local doasconf_attr = lfs.attributes(doasconf)
+ if doasconf_attr == nil then
+ chmoddoasconf = true
+ local dirattrs = lfs.attributes(etcdir)
+ if dirattrs == nil then
+ local r, err = mkdir_p(etcdir)
+ if not r then
+ return nil, err .. " (creating " .. etcdir .. ")"
+ end
+ chmodetcdir = true
+ end
+ end
+ local f = io.open(doasconf, "a")
+ if not f then
+ warnmsg("impossible to open " .. doasconf)
+ return
+ end
+ if type(pwd.doas) == "string" then
+ local rule = pwd.doas
+ rule = rule:gsub("%%u", pwd.name)
+ f:write(rule .. "\n")
+ elseif type(pwd.doas) == "table" then
+ for _, str in ipairs(pwd.doas) do
+ local rule = str
+ rule = rule:gsub("%%u", pwd.name)
+ f:write(rule .. "\n")
+ end
+ end
+ f:close()
+ if chmoddoasconf then
+ chmod(doasconf, "0640")
+ end
+ if chmodetcdir then
+ chmod(etcdir, "0755")
+ end
+end
+
+local function addsudo(pwd)
+ local chmodsudoersd = false
+ local chmodsudoers = false
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ local localbase = getlocalbase()
+ local sudoers_dir = localbase .. "/etc/sudoers.d"
+ if root then
+ sudoers_dir= root .. sudoers_dir
+ end
+ local sudoers = sudoers_dir .. "/90-nuageinit-users"
+ local sudoers_attr = lfs.attributes(sudoers)
+ if sudoers_attr == nil then
+ chmodsudoers = true
+ local dirattrs = lfs.attributes(sudoers_dir)
+ if dirattrs == nil then
+ local r, err = mkdir_p(sudoers_dir)
+ if not r then
+ return nil, err .. " (creating " .. sudoers_dir .. ")"
+ end
+ chmodsudoersd = true
+ end
+ end
+ local f = io.open(sudoers, "a")
+ if not f then
+ warnmsg("impossible to open " .. sudoers)
+ return
+ end
+ if type(pwd.sudo) == "string" then
+ f:write(pwd.name .. " " .. pwd.sudo .. "\n")
+ elseif type(pwd.sudo) == "table" then
+ for _, str in ipairs(pwd.sudo) do
+ f:write(pwd.name .. " " .. str .. "\n")
+ end
+ end
+ f:close()
+ if chmodsudoers then
+ chmod(sudoers, "0440")
+ end
+ if chmodsudoersd then
+ chmod(sudoers_dir, "0750")
+ end
+end
+
+local function update_sshd_config(key, value)
+ local sshd_config = "/etc/ssh/sshd_config"
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ if root then
+ sshd_config = root .. sshd_config
+ end
+ local f = assert(io.open(sshd_config, "r+"))
+ local tgt = assert(io.open(sshd_config .. ".nuageinit", "w"))
+ local found = false
+ local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$"
+ while true do
+ local line = f:read()
+ if line == nil then break end
+ local _, _, val = line:lower():find(pattern)
+ if val then
+ found = true
+ if val == value then
+ assert(tgt:write(line .. "\n"))
+ else
+ assert(tgt:write(key .. " " .. value .. "\n"))
+ end
+ else
+ assert(tgt:write(line .. "\n"))
+ end
+ end
+ if not found then
+ assert(tgt:write(key .. " " .. value .. "\n"))
+ end
+ assert(f:close())
+ assert(tgt:close())
+ os.rename(sshd_config .. ".nuageinit", sshd_config)
+end
+
+local function exec_change_password(user, password, type, expire)
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ local cmd = "pw "
+ if root then
+ cmd = cmd .. "-R " .. root .. " "
+ end
+ local postcmd = " -H 0"
+ local input = password
+ if type ~= nil and type == "text" then
+ postcmd = " -h 0"
+ else
+ if password == "RANDOM" then
+ input = nil
+ postcmd = " -w random"
+ end
+ end
+ cmd = cmd .. "usermod " .. user .. postcmd
+ if expire then
+ cmd = cmd .. " -p 1"
+ else
+ cmd = cmd .. " -p 0"
+ end
+ local f = io.popen(cmd .. " >/dev/null", "w")
+ if input then
+ f:write(input)
+ end
+ -- ignore stdout to avoid printing the password in case of random password
+ local r = f:close(cmd)
+ if not r then
+ warnmsg("fail to change user password ".. user)
+ warnmsg(cmd)
+ end
+end
+
+local function change_password_from_line(line, expire)
+ local user, password = line:match("%s*(%w+):(%S+)%s*")
+ local type = nil
+ if user and password then
+ if password == "R" then
+ password = "RANDOM"
+ end
+ if not password:match("^%$%d+%$%w+%$") then
+ if password ~= "RANDOM" then
+ type = "text"
+ end
+ end
+ exec_change_password(user, password, type, expire)
+ end
+end
+
+local function chpasswd(obj)
+ if type(obj) ~= "table" then
+ warnmsg("Invalid chpasswd entry, expecting an object")
+ return
+ end
+ local expire = false
+ if obj.expire ~= nil then
+ if type(obj.expire) == "boolean" then
+ expire = obj.expire
+ else
+ warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire))
+ end
+ end
+ if obj.users ~= nil then
+ if type(obj.users) ~= "table" then
+ warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users))
+ goto list
+ end
+ for _, u in ipairs(obj.users) do
+ if type(u) ~= "table" then
+ warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u))
+ goto next
+ end
+ if not u.name then
+ warnmsg("Invalid entry for chpasswd.users: missing 'name'")
+ goto next
+ end
+ if not u.password then
+ warnmsg("Invalid entry for chpasswd.users: missing 'password'")
+ goto next
+ end
+ exec_change_password(u.name, u.password, u.type, expire)
+ ::next::
+ end
+ end
+ ::list::
+ if obj.list ~= nil then
+ warnmsg("chpasswd.list is deprecated consider using chpasswd.users")
+ if type(obj.list) == "string" then
+ for line in obj.list:gmatch("[^\n]+") do
+ change_password_from_line(line, expire)
+ end
+ elseif type(obj.list) == "table" then
+ for _, u in ipairs(obj.list) do
+ change_password_from_line(u, expire)
+ end
+ end
+ end
+end
+
+local function settimezone(timezone)
+ if timezone == nil then
+ return
+ end
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ if not root then
+ root = "/"
+ end
+
+ f, _, rc = os.execute("tzsetup -s -C " .. root .. " " .. timezone)
+
+ if not f then
+ warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )")
+ return
+ end
+end
+
+local function pkg_bootstrap()
+ if os.getenv("NUAGE_RUN_TESTS") then
+ return true
+ end
+ if os.execute("pkg -N 2>/dev/null") then
+ return true
+ end
+ print("Bootstrapping pkg")
+ return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap")
+end
+
+local function install_package(package)
+ if package == nil then
+ return true
+ end
+ local install_cmd = "pkg install -y " .. package
+ local test_cmd = "pkg info -q " .. package
+ if os.getenv("NUAGE_RUN_TESTS") then
+ print(install_cmd)
+ print(test_cmd)
+ return true
+ end
+ if os.execute(test_cmd) then
+ return true
+ end
+ return os.execute(install_cmd)
+end
+
+local function run_pkg_cmd(subcmd)
+ local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd
+ if os.getenv("NUAGE_RUN_TESTS") then
+ print(cmd)
+ return true
+ end
+ return os.execute(cmd)
+end
+local function update_packages()
+ return run_pkg_cmd("update")
+end
+
+local function upgrade_packages()
+ return run_pkg_cmd("upgrade")
+end
+
+local function addfile(file, defer)
+ if type(file) ~= "table" then
+ return false, "Invalid object"
+ end
+ if defer and not file.defer then
+ return true
+ end
+ if not defer and file.defer then
+ return true
+ end
+ if not file.path then
+ return false, "No path provided for the file to write"
+ end
+ local content = nil
+ if file.content then
+ if file.encoding then
+ if file.encoding == "b64" or file.encoding == "base64" then
+ content = decode_base64(file.content)
+ else
+ return false, "Unsupported encoding: " .. file.encoding
+ end
+ else
+ content = file.content
+ end
+ end
+ local mode = "w"
+ if file.append then
+ mode = "a"
+ end
+
+ local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+ if not root then
+ root = ""
+ end
+ local filepath = root .. file.path
+ local f = assert(io.open(filepath, mode))
+ if content then
+ f:write(content)
+ end
+ f:close()
+ if file.permissions then
+ chmod(filepath, file.permissions)
+ end
+ if file.owner then
+ local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
+ if not owner then
+ owner = file.owner
+ end
+ chown(filepath, owner, group)
+ end
+ return true
+end
+
+local n = {
+ warn = warnmsg,
+ err = errmsg,
+ chmod = chmod,
+ chown = chown,
+ dirname = dirname,
+ mkdir_p = mkdir_p,
+ sethostname = sethostname,
+ settimezone = settimezone,
+ adduser = adduser,
+ addgroup = addgroup,
+ addsshkey = addsshkey,
+ update_sshd_config = update_sshd_config,
+ chpasswd = chpasswd,
+ pkg_bootstrap = pkg_bootstrap,
+ install_package = install_package,
+ update_packages = update_packages,
+ upgrade_packages = upgrade_packages,
+ addsudo = addsudo,
+ adddoas = adddoas,
+ addfile = addfile
+}
+
+return n