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