xref: /freebsd/libexec/nuageinit/nuage.lua (revision 8b70a203be10411c560ed303ab25713d70b316e9)
1---
2-- SPDX-License-Identifier: BSD-2-Clause
3--
4-- Copyright(c) 2022-2025 Baptiste Daroussin <bapt@FreeBSD.org>
5-- Copyright(c) 2025 Jesús Daniel Colmenares Oviedo <dtxdf@FreeBSD.org>
6
7local unistd = require("posix.unistd")
8local sys_stat = require("posix.sys.stat")
9local lfs = require("lfs")
10
11local function getlocalbase()
12	local f = io.popen("sysctl -in user.localbase 2> /dev/null")
13	local localbase = f:read("*l")
14	f:close()
15	if localbase == nil or localbase:len() == 0 then
16		-- fallback
17		localbase = "/usr/local"
18	end
19	return localbase
20end
21
22local function decode_base64(input)
23	local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
24	input = string.gsub(input, '[^'..b..'=]', '')
25
26	local result = {}
27	local bits = ''
28
29	-- convert all characters in bits
30	for i = 1, #input do
31		local x = input:sub(i, i)
32		if x == '=' then
33			break
34		end
35		local f = b:find(x) - 1
36		for j = 6, 1, -1 do
37			bits = bits .. (f % 2^j - f % 2^(j-1) > 0 and '1' or '0')
38		end
39	end
40
41	for i = 1, #bits, 8 do
42		local byte = bits:sub(i, i + 7)
43		if #byte == 8 then
44			local c = 0
45			for j = 1, 8 do
46				c = c + (byte:sub(j, j) == '1' and 2^(8 - j) or 0)
47			end
48			table.insert(result, string.char(c))
49		end
50	end
51
52	return table.concat(result)
53end
54
55local function shell_escape(s)
56	return "'" .. string.gsub(s, "'", "'\\''") .. "'"
57end
58
59local function warnmsg(str, prepend)
60	if not str then
61		return
62	end
63	local tag = ""
64	if prepend ~= false then
65		tag = "nuageinit: "
66	end
67	io.stderr:write(tag .. str .. "\n")
68end
69
70local function errmsg(str, prepend)
71	warnmsg(str, prepend)
72	os.exit(1)
73end
74
75local function chmod(path, mode)
76	mode = tonumber(mode, 8)
77	local _, err, msg = sys_stat.chmod(path, mode)
78	if err then
79		errmsg("chmod(" .. path .. ", " .. mode .. ") failed: " .. msg)
80	end
81end
82
83local function chown(path, owner, group)
84	local _, err, msg = unistd.chown(path, owner, group)
85	if err then
86		errmsg("chown(" .. path .. ", " .. owner .. ", " .. group .. ") failed: " .. msg)
87	end
88end
89
90local function dirname(oldpath)
91	if not oldpath then
92		return nil
93	end
94	local path = oldpath:gsub("[^/]+/*$", "")
95	if path == "" then
96		return nil
97	end
98	return path
99end
100
101local function mkdir_p(path)
102	if lfs.attributes(path, "mode") ~= nil then
103		return true
104	end
105	local r, err = mkdir_p(dirname(path))
106	if not r then
107		return nil, err .. " (creating " .. path .. ")"
108	end
109	return lfs.mkdir(path)
110end
111
112local function sethostname(hostname)
113	if hostname == nil then
114		return
115	end
116	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
117	if not root then
118		root = ""
119	end
120	local hostnamepath = root .. "/etc/rc.conf.d/hostname"
121
122	mkdir_p(dirname(hostnamepath))
123	local f, err = io.open(hostnamepath, "w")
124	if not f then
125		warnmsg("Impossible to open " .. hostnamepath .. ":" .. err)
126		return
127	end
128	f:write('hostname="' .. hostname:gsub('"', '\\"') .. '"\n')
129	f:close()
130end
131
132local function splitlist(list)
133	local ret = {}
134	if type(list) == "string" then
135		for str in list:gmatch("([^, ]+)") do
136			ret[#ret + 1] = str
137		end
138	elseif type(list) == "table" then
139		ret = list
140	else
141		warnmsg("Invalid type " .. type(list) .. ", expecting table or string")
142	end
143	return ret
144end
145
146local function splitlines(s)
147	local ret = {}
148
149	for line in string.gmatch(s, "[^\n]+") do
150		ret[#ret + 1] = line
151	end
152
153	return ret
154end
155
156local function getgroups()
157	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
158	local cmd = "pw "
159	if root then
160		cmd = cmd .. "-R " .. root .. " "
161	end
162
163	local f = io.popen(cmd .. "groupshow -a 2> /dev/null | cut -d: -f1")
164	local groups = f:read("*a")
165	f:close()
166
167	return splitlines(groups)
168end
169
170local function checkgroup(group)
171	local groups = getgroups()
172
173	for _, group2chk in ipairs(groups) do
174		if group == group2chk then
175			return true
176		end
177	end
178
179	return false
180end
181
182local function purge_group(groups)
183	local ret = {}
184
185	for _, group in ipairs(groups) do
186		if checkgroup(group) then
187			ret[#ret + 1] = group
188		else
189			warnmsg("ignoring non-existent group '" .. group .. "'")
190		end
191	end
192
193	return ret
194end
195
196local function adduser(pwd)
197	if (type(pwd) ~= "table") then
198		warnmsg("Argument should be a table")
199		return nil
200	end
201	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
202	local cmd = "pw "
203	if root then
204		cmd = cmd .. "-R " .. root .. " "
205	end
206	local f = io.popen(cmd .. " usershow " .. shell_escape(pwd.name) .. " -7 2> /dev/null")
207	local pwdstr = f:read("*a")
208	f:close()
209	if pwdstr:len() ~= 0 then
210		return pwdstr:match("%a+:.+:%d+:%d+:.*:(.*):.*")
211	end
212	if not pwd.gecos then
213		pwd.gecos = pwd.name .. " User"
214	end
215	if not pwd.homedir then
216		pwd.homedir = "/home/" .. pwd.name
217	end
218	local extraargs = ""
219	if pwd.groups then
220		local list = splitlist(pwd.groups)
221		-- pw complains if the group does not exist, so if the user
222		-- specifies one that cannot be found, nuageinit will generate
223		-- an exception and exit, unlike cloud-init, which only issues
224		-- a warning but creates the user anyway.
225		list = purge_group(list)
226		if #list > 0 then
227			local escaped_list = {}
228			for _, g in ipairs(list) do
229				table.insert(escaped_list, shell_escape(g))
230			end
231			extraargs = " -G " .. table.concat(escaped_list, ",")
232		end
233	end
234	-- pw will automatically create a group named after the username
235	-- do not add a -g option in this case
236	if pwd.primary_group and pwd.primary_group ~= pwd.name then
237		extraargs = extraargs .. " -g " .. shell_escape(pwd.primary_group)
238	end
239	if not pwd.no_create_home then
240		extraargs = extraargs .. " -m "
241	end
242	if not pwd.shell then
243		pwd.shell = "/bin/sh"
244	end
245	local precmd = ""
246	local postcmd = ""
247	local input = nil
248	if pwd.passwd then
249		input = pwd.passwd
250		postcmd = " -H 0"
251	elseif pwd.plain_text_passwd then
252		input = pwd.plain_text_passwd
253		postcmd = " -h 0"
254	end
255	cmd = precmd .. "pw "
256	if root then
257		cmd = cmd .. "-R " .. root .. " "
258	end
259	cmd = cmd .. "useradd -n " .. shell_escape(pwd.name) .. " -M 0755 -w none "
260	cmd = cmd .. extraargs .. " -c " .. shell_escape(pwd.gecos)
261	cmd = cmd .. " -d " .. shell_escape(pwd.homedir) .. " -s " .. shell_escape(pwd.shell) .. postcmd
262
263	f = io.popen(cmd, "w")
264	if input then
265		f:write(input)
266	end
267	local r = f:close(cmd)
268	if not r then
269		warnmsg("fail to add user " .. pwd.name)
270		warnmsg(cmd)
271		return nil
272	end
273	if pwd.locked then
274		cmd = "pw "
275		if root then
276			cmd = cmd .. "-R " .. root .. " "
277		end
278		cmd = cmd .. "lock " .. shell_escape(pwd.name)
279		os.execute(cmd)
280	end
281	return pwd.homedir
282end
283
284local function addgroup(grp)
285	if (type(grp) ~= "table") then
286		warnmsg("Argument should be a table")
287		return false
288	end
289	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
290	local cmd = "pw "
291	if root then
292		cmd = cmd .. "-R " .. root .. " "
293	end
294	local f = io.popen(cmd .. " groupshow " .. shell_escape(grp.name) .. " 2> /dev/null")
295	local grpstr = f:read("*a")
296	f:close()
297	if grpstr:len() ~= 0 then
298		return true
299	end
300	local extraargs = ""
301	if grp.members then
302		local list = splitlist(grp.members)
303		local escaped_list = {}
304		for _, m in ipairs(list) do
305			table.insert(escaped_list, shell_escape(m))
306		end
307		extraargs = " -M " .. table.concat(escaped_list, ",")
308	end
309	cmd = "pw "
310	if root then
311		cmd = cmd .. "-R " .. root .. " "
312	end
313	cmd = cmd .. "groupadd -n " .. shell_escape(grp.name) .. extraargs
314	local r = os.execute(cmd)
315	if not r then
316		warnmsg("fail to add group " .. grp.name)
317		warnmsg(cmd)
318		return false
319	end
320	return true
321end
322
323local function addsshkey(homedir, key)
324	local chownak = false
325	local chowndotssh = false
326	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
327	if root then
328		homedir = root .. "/" .. homedir
329	end
330	local ak_path = homedir .. "/.ssh/authorized_keys"
331	local dotssh_path = homedir .. "/.ssh"
332	local dirattrs = lfs.attributes(ak_path)
333	if dirattrs == nil then
334		chownak = true
335		dirattrs = lfs.attributes(dotssh_path)
336		if dirattrs == nil then
337			assert(lfs.mkdir(dotssh_path))
338			chowndotssh = true
339			dirattrs = lfs.attributes(homedir)
340		end
341	end
342
343	local f = io.open(ak_path, "a")
344	if not f then
345		warnmsg("impossible to open " .. ak_path)
346		return
347	end
348	f:write(key .. "\n")
349	f:close()
350	if chownak then
351		chmod(ak_path, "0600")
352		chown(ak_path, dirattrs.uid, dirattrs.gid)
353	end
354	if chowndotssh then
355		chmod(dotssh_path, "0700")
356		chown(dotssh_path, dirattrs.uid, dirattrs.gid)
357	end
358end
359
360local function adddoas(pwd)
361	local chmodetcdir = false
362	local chmoddoasconf = false
363	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
364	local localbase = getlocalbase()
365	local etcdir = localbase .. "/etc"
366	if root then
367		etcdir= root .. etcdir
368	end
369	local doasconf = etcdir .. "/doas.conf"
370	local doasconf_attr = lfs.attributes(doasconf)
371	if doasconf_attr == nil then
372		chmoddoasconf = true
373		local dirattrs = lfs.attributes(etcdir)
374		if dirattrs == nil then
375			local r, err = mkdir_p(etcdir)
376			if not r then
377				return nil, err .. " (creating " .. etcdir .. ")"
378			end
379			chmodetcdir = true
380		end
381	end
382	local f = io.open(doasconf, "a")
383	if not f then
384		warnmsg("impossible to open " .. doasconf)
385		return
386	end
387	if type(pwd.doas) == "string" then
388		local rule = pwd.doas
389		rule = rule:gsub("%%u", pwd.name)
390		f:write(rule .. "\n")
391	elseif type(pwd.doas) == "table" then
392		for _, str in ipairs(pwd.doas) do
393			local rule = str
394			rule = rule:gsub("%%u", pwd.name)
395			f:write(rule .. "\n")
396		end
397	end
398	f:close()
399	if chmoddoasconf then
400		chmod(doasconf, "0640")
401	end
402	if chmodetcdir then
403		chmod(etcdir, "0755")
404	end
405end
406
407local function addsudo(pwd)
408	local chmodsudoersd = false
409	local chmodsudoers = false
410	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
411	local localbase = getlocalbase()
412	local sudoers_dir = localbase .. "/etc/sudoers.d"
413	if root then
414		sudoers_dir= root .. sudoers_dir
415	end
416	local sudoers = sudoers_dir .. "/90-nuageinit-users"
417	local sudoers_attr = lfs.attributes(sudoers)
418	if sudoers_attr == nil then
419		chmodsudoers = true
420		local dirattrs = lfs.attributes(sudoers_dir)
421		if dirattrs == nil then
422			local r, err = mkdir_p(sudoers_dir)
423			if not r then
424				return nil, err .. " (creating " .. sudoers_dir .. ")"
425			end
426			chmodsudoersd = true
427		end
428	end
429	local f = io.open(sudoers, "a")
430	if not f then
431		warnmsg("impossible to open " .. sudoers)
432		return
433	end
434	if type(pwd.sudo) == "string" then
435		f:write(pwd.name .. " " .. pwd.sudo .. "\n")
436	elseif type(pwd.sudo) == "table" then
437		for _, str in ipairs(pwd.sudo) do
438			f:write(pwd.name .. " " .. str .. "\n")
439		end
440	end
441	f:close()
442	if chmodsudoers then
443		chmod(sudoers, "0440")
444	end
445	if chmodsudoersd then
446		chmod(sudoers_dir, "0750")
447	end
448end
449
450local function update_sshd_config(key, value)
451	local sshd_config = "/etc/ssh/sshd_config"
452	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
453	if root then
454		sshd_config = root .. sshd_config
455	end
456	local f = assert(io.open(sshd_config, "r+"))
457	local tgt = assert(io.open(sshd_config .. ".nuageinit", "w"))
458	local found = false
459	local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$"
460	while true do
461		local line = f:read()
462		if line == nil then break end
463		local _, _, val = line:lower():find(pattern)
464		if val then
465			found = true
466			if val == value then
467				assert(tgt:write(line .. "\n"))
468			else
469				assert(tgt:write(key .. " " .. value .. "\n"))
470			end
471		else
472			assert(tgt:write(line .. "\n"))
473		end
474	end
475	if not found then
476		assert(tgt:write(key .. " " .. value .. "\n"))
477	end
478	assert(f:close())
479	assert(tgt:close())
480	os.rename(sshd_config .. ".nuageinit", sshd_config)
481end
482
483local function exec_change_password(user, password, type, expire)
484	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
485	local cmd = "pw "
486	if root then
487		cmd = cmd .. "-R " .. root .. " "
488	end
489	local postcmd = " -H 0"
490	local input = password
491	if type ~= nil and type == "text" then
492		postcmd = " -h 0"
493	else
494		if password == "RANDOM" then
495			input = nil
496			postcmd = " -w random"
497		end
498	end
499	cmd = cmd .. "usermod " .. shell_escape(user) .. postcmd
500	if expire then
501		cmd = cmd .. " -p 1"
502	else
503		cmd = cmd .. " -p 0"
504	end
505	local f = io.popen(cmd .. " >/dev/null", "w")
506	if input then
507		f:write(input)
508	end
509	-- ignore stdout to avoid printing the password in case of random password
510	local r = f:close(cmd)
511	if not r then
512		warnmsg("fail to change user password ".. user)
513		warnmsg(cmd)
514	end
515end
516
517local function change_password_from_line(line, expire)
518	local user, password = line:match("%s*(%w+):(%S+)%s*")
519	local type = nil
520	if user and password then
521		if password == "R" then
522			password = "RANDOM"
523		end
524		if not password:match("^%$%d+%$%w+%$") then
525			if password ~= "RANDOM" then
526				type = "text"
527			end
528		end
529		exec_change_password(user, password, type, expire)
530	end
531end
532
533local function chpasswd(obj)
534	if type(obj) ~= "table" then
535		warnmsg("Invalid chpasswd entry, expecting an object")
536		return
537	end
538	local expire = false
539	if obj.expire ~= nil then
540		if type(obj.expire) == "boolean" then
541			expire = obj.expire
542		else
543			warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire))
544		end
545	end
546	if obj.users ~= nil then
547		if type(obj.users) ~= "table" then
548			warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users))
549			goto list
550		end
551		for _, u in ipairs(obj.users) do
552			if type(u) ~= "table" then
553				warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u))
554				goto next
555			end
556			if not u.name then
557				warnmsg("Invalid entry for chpasswd.users: missing 'name'")
558				goto next
559			end
560			if not u.password then
561				warnmsg("Invalid entry for chpasswd.users: missing 'password'")
562				goto next
563			end
564			exec_change_password(u.name, u.password, u.type, expire)
565			::next::
566		end
567	end
568	::list::
569	if obj.list ~= nil then
570		warnmsg("chpasswd.list is deprecated consider using chpasswd.users")
571		if type(obj.list) == "string" then
572			for line in obj.list:gmatch("[^\n]+") do
573				change_password_from_line(line, expire)
574			end
575		elseif type(obj.list) == "table" then
576			for _, u in ipairs(obj.list) do
577				change_password_from_line(u, expire)
578			end
579		end
580	end
581end
582
583local function settimezone(timezone)
584	if timezone == nil then
585		return
586	end
587	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
588	if not root then
589		root = "/"
590	end
591
592	local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone))
593
594	if not f then
595		warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )")
596		return
597	end
598end
599
600local function pkg_bootstrap()
601	if os.getenv("NUAGE_RUN_TESTS") then
602		return true
603	end
604	if os.execute("pkg -N 2>/dev/null") then
605		return true
606	end
607	print("Bootstrapping pkg")
608	return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap")
609end
610
611local function install_package(package)
612	if package == nil then
613		return true
614	end
615	local install_cmd = "pkg install -y " .. shell_escape(package)
616	local test_cmd = "pkg info -q " .. shell_escape(package)
617	if os.getenv("NUAGE_RUN_TESTS") then
618		print(install_cmd)
619		print(test_cmd)
620		return true
621	end
622	if os.execute(test_cmd) then
623		return true
624	end
625	return os.execute(install_cmd)
626end
627
628local function run_pkg_cmd(subcmd)
629	local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd
630	if os.getenv("NUAGE_RUN_TESTS") then
631		print(cmd)
632		return true
633	end
634	return os.execute(cmd)
635end
636local function update_packages()
637	return run_pkg_cmd("update")
638end
639
640local function upgrade_packages()
641	return run_pkg_cmd("upgrade")
642end
643
644local function addfile(file, defer)
645	if type(file) ~= "table" then
646		return false, "Invalid object"
647	end
648	if defer and not file.defer then
649		return true
650	end
651	if not defer and file.defer then
652		return true
653	end
654	if not file.path then
655		return false, "No path provided for the file to write"
656	end
657	local content = nil
658	if file.content then
659		if file.encoding then
660			if file.encoding == "b64" or file.encoding == "base64" then
661				content = decode_base64(file.content)
662			else
663				return false, "Unsupported encoding: " .. file.encoding
664			end
665		else
666			content = file.content
667		end
668	end
669	local mode = "w"
670	if file.append then
671		mode = "a"
672	end
673
674	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
675	if not root then
676		root = ""
677	end
678	local filepath = root .. file.path
679	local f = assert(io.open(filepath, mode))
680	if content then
681		f:write(content)
682	end
683	f:close()
684	if file.permissions then
685		chmod(filepath, file.permissions)
686	end
687	if file.owner then
688		local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
689		if not owner then
690			owner = file.owner
691		end
692		chown(filepath, owner, group)
693	end
694	return true
695end
696
697local n = {
698	shell_escape = shell_escape,
699	warn = warnmsg,
700	err = errmsg,
701	chmod = chmod,
702	chown = chown,
703	dirname = dirname,
704	mkdir_p = mkdir_p,
705	sethostname = sethostname,
706	settimezone = settimezone,
707	adduser = adduser,
708	addgroup = addgroup,
709	addsshkey = addsshkey,
710	update_sshd_config = update_sshd_config,
711	chpasswd = chpasswd,
712	pkg_bootstrap = pkg_bootstrap,
713	install_package = install_package,
714	update_packages = update_packages,
715	upgrade_packages = upgrade_packages,
716	addsudo = addsudo,
717	adddoas = adddoas,
718	addfile = addfile
719}
720
721return n
722