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