xref: /freebsd/libexec/nuageinit/nuage.lua (revision 22c1f5d0ec215e36dd4448b9128b856b5441d21c)
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 delete_ssh_host_keys(root)
543	local ssh_dir = root .. "/etc/ssh"
544	local attrs = lfs.attributes(ssh_dir)
545	if not attrs or attrs.mode ~= "directory" then
546		return
547	end
548	for entry in lfs.dir(ssh_dir) do
549		if entry:match("^ssh_host_.*key") or entry:match("^ssh_host_.*key%.pub") then
550			os.remove(ssh_dir .. "/" .. entry)
551		end
552	end
553end
554
555local function exec_change_password(user, password, type, expire)
556	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
557	local cmd = "pw "
558	if root then
559		cmd = cmd .. "-R " .. root .. " "
560	end
561	local postcmd = " -H 0"
562	local input = password
563	if type ~= nil and type == "text" then
564		postcmd = " -h 0"
565	else
566		if password == "RANDOM" then
567			input = nil
568			postcmd = " -w random"
569		end
570	end
571	cmd = cmd .. "usermod " .. shell_escape(user) .. postcmd
572	if expire then
573		cmd = cmd .. " -p 1"
574	else
575		cmd = cmd .. " -p 0"
576	end
577	local f = io.popen(cmd .. " >/dev/null", "w")
578	if input then
579		f:write(input)
580	end
581	-- ignore stdout to avoid printing the password in case of random password
582	local r = f:close()
583	if not r then
584		warnmsg("fail to change user password ".. user)
585		warnmsg(cmd)
586	end
587end
588
589local function change_password_from_line(line, expire)
590	local user, password = line:match("%s*(%w+):(%S+)%s*")
591	local type = nil
592	if user and password then
593		if password == "R" then
594			password = "RANDOM"
595		end
596		if not password:match("^%$%d+%$%w+%$") then
597			if password ~= "RANDOM" then
598				type = "text"
599			end
600		end
601		exec_change_password(user, password, type, expire)
602	end
603end
604
605local function chpasswd(obj)
606	if type(obj) ~= "table" then
607		warnmsg("Invalid chpasswd entry, expecting an object")
608		return
609	end
610	local expire = false
611	if obj.expire ~= nil then
612		if type(obj.expire) == "boolean" then
613			expire = obj.expire
614		else
615			warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire))
616		end
617	end
618	if obj.users ~= nil then
619		if type(obj.users) ~= "table" then
620			warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users))
621		else
622			for _, u in ipairs(obj.users) do
623				if type(u) ~= "table" then
624					warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u))
625				elseif not u.name then
626					warnmsg("Invalid entry for chpasswd.users: missing 'name'")
627				elseif not u.password then
628					warnmsg("Invalid entry for chpasswd.users: missing 'password'")
629				else
630					exec_change_password(u.name, u.password, u.type, expire)
631				end
632			end
633		end
634	end
635	if obj.list ~= nil then
636		warnmsg("chpasswd.list is deprecated consider using chpasswd.users")
637		if type(obj.list) == "string" then
638			for line in obj.list:gmatch("[^\n]+") do
639				change_password_from_line(line, expire)
640			end
641		elseif type(obj.list) == "table" then
642			for _, u in ipairs(obj.list) do
643				change_password_from_line(u, expire)
644			end
645		end
646	end
647end
648
649local function settimezone(timezone)
650	if timezone == nil then
651		return
652	end
653	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
654	if not root then
655		root = "/"
656	end
657
658	local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone))
659
660	if not f then
661		warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )")
662		return
663	end
664end
665
666local function pkg_bootstrap()
667	if os.getenv("NUAGE_RUN_TESTS") then
668		return true
669	end
670	if os.execute("pkg -N 2>/dev/null") then
671		return true
672	end
673	print("Bootstrapping pkg")
674	return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap")
675end
676
677local function install_package(package)
678	if package == nil then
679		return true
680	end
681	local install_cmd = "pkg install -y " .. shell_escape(package)
682	local test_cmd = "pkg info -q " .. shell_escape(package)
683	if os.getenv("NUAGE_RUN_TESTS") then
684		print(install_cmd)
685		print(test_cmd)
686		return true
687	end
688	if os.execute(test_cmd) then
689		return true
690	end
691	return os.execute(install_cmd)
692end
693
694local function run_pkg_cmd(subcmd)
695	local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd
696	if os.getenv("NUAGE_RUN_TESTS") then
697		print(cmd)
698		return true
699	end
700	return os.execute(cmd)
701end
702local function update_packages()
703	return run_pkg_cmd("update")
704end
705
706local function upgrade_packages()
707	return run_pkg_cmd("upgrade")
708end
709
710local function addfile(file, defer)
711	if type(file) ~= "table" then
712		return false, "Invalid object"
713	end
714	if defer and not file.defer then
715		return true
716	end
717	if not defer and file.defer then
718		return true
719	end
720	if not file.path then
721		return false, "No path provided for the file to write"
722	end
723	local content = nil
724	if file.content then
725		if file.encoding then
726			if file.encoding == "b64" or file.encoding == "base64" then
727				content = decode_base64(file.content)
728			else
729				return false, "Unsupported encoding: " .. file.encoding
730			end
731		else
732			content = file.content
733		end
734	end
735	local mode = "w"
736	if file.append then
737		mode = "a"
738	end
739
740	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
741	if not root then
742		root = ""
743	end
744	local filepath = root .. file.path
745	local f = assert(io.open(filepath, mode))
746	if content then
747		f:write(content)
748	end
749	f:close()
750	if file.permissions then
751		chmod(filepath, file.permissions)
752	end
753	if file.owner then
754		local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
755		if not owner then
756			owner = file.owner
757		end
758		chown(filepath, owner, group)
759	end
760	return true
761end
762
763local n = {
764	shell_escape = shell_escape,
765	warn = warnmsg,
766	err = errmsg,
767	chmod = chmod,
768	chown = chown,
769	dirname = dirname,
770	mkdir_p = mkdir_p,
771	sethostname = sethostname,
772	settimezone = settimezone,
773	adduser = adduser,
774	addgroup = addgroup,
775	addsshkey = addsshkey,
776	update_sshd_config = update_sshd_config,
777	delete_ssh_host_keys = delete_ssh_host_keys,
778	chpasswd = chpasswd,
779	pkg_bootstrap = pkg_bootstrap,
780	install_package = install_package,
781	update_packages = update_packages,
782	upgrade_packages = upgrade_packages,
783	addsudo = addsudo,
784	adddoas = adddoas,
785	addfile = addfile
786}
787
788return n
789