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