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