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