xref: /freebsd/libexec/nuageinit/nuage.lua (revision ba5df7a2d03cd5624b1825ca8d4c39dcaace7796)
1---
2-- SPDX-License-Identifier: BSD-2-Clause
3--
4-- Copyright(c) 2022-2025 Baptiste Daroussin <bapt@FreeBSD.org>
5
6local unistd = require("posix.unistd")
7local sys_stat = require("posix.sys.stat")
8local lfs = require("lfs")
9
10local function decode_base64(input)
11	local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
12	input = string.gsub(input, '[^'..b..'=]', '')
13
14	local result = {}
15	local bits = ''
16
17	-- convert all characters in bits
18	for i = 1, #input do
19		local x = input:sub(i, i)
20		if x == '=' then
21			break
22		end
23		local f = b:find(x) - 1
24		for j = 6, 1, -1 do
25			bits = bits .. (f % 2^j - f % 2^(j-1) > 0 and '1' or '0')
26		end
27	end
28
29	for i = 1, #bits, 8 do
30		local byte = bits:sub(i, i + 7)
31		if #byte == 8 then
32			local c = 0
33			for j = 1, 8 do
34				c = c + (byte:sub(j, j) == '1' and 2^(8 - j) or 0)
35			end
36			table.insert(result, string.char(c))
37		end
38	end
39
40	return table.concat(result)
41end
42
43local function warnmsg(str, prepend)
44	if not str then
45		return
46	end
47	local tag = ""
48	if prepend ~= false then
49		tag = "nuageinit: "
50	end
51	io.stderr:write(tag .. str .. "\n")
52end
53
54local function errmsg(str, prepend)
55	warnmsg(str, prepend)
56	os.exit(1)
57end
58
59local function chmod(path, mode)
60	local mode = tonumber(mode, 8)
61	local _, err, msg = sys_stat.chmod(path, mode)
62	if err then
63		errmsg("chmod(" .. path .. ", " .. mode .. ") failed: " .. msg)
64	end
65end
66
67local function chown(path, owner, group)
68	local _, err, msg = unistd.chown(path, owner, group)
69	if err then
70		errmsg("chown(" .. path .. ", " .. owner .. ", " .. group .. ") failed: " .. msg)
71	end
72end
73
74local function dirname(oldpath)
75	if not oldpath then
76		return nil
77	end
78	local path = oldpath:gsub("[^/]+/*$", "")
79	if path == "" then
80		return nil
81	end
82	return path
83end
84
85local function mkdir_p(path)
86	if lfs.attributes(path, "mode") ~= nil then
87		return true
88	end
89	local r, err = mkdir_p(dirname(path))
90	if not r then
91		return nil, err .. " (creating " .. path .. ")"
92	end
93	return lfs.mkdir(path)
94end
95
96local function sethostname(hostname)
97	if hostname == nil then
98		return
99	end
100	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
101	if not root then
102		root = ""
103	end
104	local hostnamepath = root .. "/etc/rc.conf.d/hostname"
105
106	mkdir_p(dirname(hostnamepath))
107	local f, err = io.open(hostnamepath, "w")
108	if not f then
109		warnmsg("Impossible to open " .. hostnamepath .. ":" .. err)
110		return
111	end
112	f:write('hostname="' .. hostname .. '"\n')
113	f:close()
114end
115
116local function splitlist(list)
117	local ret = {}
118	if type(list) == "string" then
119		for str in list:gmatch("([^, ]+)") do
120			ret[#ret + 1] = str
121		end
122	elseif type(list) == "table" then
123		ret = list
124	else
125		warnmsg("Invalid type " .. type(list) .. ", expecting table or string")
126	end
127	return ret
128end
129
130local function adduser(pwd)
131	if (type(pwd) ~= "table") then
132		warnmsg("Argument should be a table")
133		return nil
134	end
135	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
136	local cmd = "pw "
137	if root then
138		cmd = cmd .. "-R " .. root .. " "
139	end
140	local f = io.popen(cmd .. " usershow " .. pwd.name .. " -7 2> /dev/null")
141	local pwdstr = f:read("*a")
142	f:close()
143	if pwdstr:len() ~= 0 then
144		return pwdstr:match("%a+:.+:%d+:%d+:.*:(.*):.*")
145	end
146	if not pwd.gecos then
147		pwd.gecos = pwd.name .. " User"
148	end
149	if not pwd.homedir then
150		pwd.homedir = "/home/" .. pwd.name
151	end
152	local extraargs = ""
153	if pwd.groups then
154		local list = splitlist(pwd.groups)
155		extraargs = " -G " .. table.concat(list, ",")
156	end
157	-- pw will automatically create a group named after the username
158	-- do not add a -g option in this case
159	if pwd.primary_group and pwd.primary_group ~= pwd.name then
160		extraargs = extraargs .. " -g " .. pwd.primary_group
161	end
162	if not pwd.no_create_home then
163		extraargs = extraargs .. " -m "
164	end
165	if not pwd.shell then
166		pwd.shell = "/bin/sh"
167	end
168	local precmd = ""
169	local postcmd = ""
170	local input = nil
171	if pwd.passwd then
172		input = pwd.passwd
173		postcmd = " -H 0"
174	elseif pwd.plain_text_passwd then
175		input = pwd.plain_text_passwd
176		postcmd = " -h 0"
177	end
178	cmd = precmd .. "pw "
179	if root then
180		cmd = cmd .. "-R " .. root .. " "
181	end
182	cmd = cmd .. "useradd -n " .. pwd.name .. " -M 0755 -w none "
183	cmd = cmd .. extraargs .. " -c '" .. pwd.gecos
184	cmd = cmd .. "' -d '" .. pwd.homedir .. "' -s " .. pwd.shell .. postcmd
185
186	f = io.popen(cmd, "w")
187	if input then
188		f:write(input)
189	end
190	local r = f:close(cmd)
191	if not r then
192		warnmsg("fail to add user " .. pwd.name)
193		warnmsg(cmd)
194		return nil
195	end
196	if pwd.locked then
197		cmd = "pw "
198		if root then
199			cmd = cmd .. "-R " .. root .. " "
200		end
201		cmd = cmd .. "lock " .. pwd.name
202		os.execute(cmd)
203	end
204	return pwd.homedir
205end
206
207local function addgroup(grp)
208	if (type(grp) ~= "table") then
209		warnmsg("Argument should be a table")
210		return false
211	end
212	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
213	local cmd = "pw "
214	if root then
215		cmd = cmd .. "-R " .. root .. " "
216	end
217	local f = io.popen(cmd .. " groupshow " .. grp.name .. " 2> /dev/null")
218	local grpstr = f:read("*a")
219	f:close()
220	if grpstr:len() ~= 0 then
221		return true
222	end
223	local extraargs = ""
224	if grp.members then
225		local list = splitlist(grp.members)
226		extraargs = " -M " .. table.concat(list, ",")
227	end
228	cmd = "pw "
229	if root then
230		cmd = cmd .. "-R " .. root .. " "
231	end
232	cmd = cmd .. "groupadd -n " .. grp.name .. extraargs
233	local r = os.execute(cmd)
234	if not r then
235		warnmsg("fail to add group " .. grp.name)
236		warnmsg(cmd)
237		return false
238	end
239	return true
240end
241
242local function addsshkey(homedir, key)
243	local chownak = false
244	local chowndotssh = false
245	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
246	if root then
247		homedir = root .. "/" .. homedir
248	end
249	local ak_path = homedir .. "/.ssh/authorized_keys"
250	local dotssh_path = homedir .. "/.ssh"
251	local dirattrs = lfs.attributes(ak_path)
252	if dirattrs == nil then
253		chownak = true
254		dirattrs = lfs.attributes(dotssh_path)
255		if dirattrs == nil then
256			assert(lfs.mkdir(dotssh_path))
257			chowndotssh = true
258			dirattrs = lfs.attributes(homedir)
259		end
260	end
261
262	local f = io.open(ak_path, "a")
263	if not f then
264		warnmsg("impossible to open " .. ak_path)
265		return
266	end
267	f:write(key .. "\n")
268	f:close()
269	if chownak then
270		chmod(ak_path, "0600")
271		chown(ak_path, dirattrs.uid, dirattrs.gid)
272	end
273	if chowndotssh then
274		chmod(dotssh_path, "0700")
275		chown(dotssh_path, dirattrs.uid, dirattrs.gid)
276	end
277end
278
279local function addsudo(pwd)
280	local chmodsudoersd = false
281	local chmodsudoers = false
282	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
283	local sudoers_dir = "/usr/local/etc/sudoers.d"
284	if root then
285		sudoers_dir= root .. sudoers_dir
286	end
287	local sudoers = sudoers_dir .. "/90-nuageinit-users"
288	local sudoers_attr = lfs.attributes(sudoers)
289	if sudoers_attr == nil then
290		chmodsudoers = true
291		local dirattrs = lfs.attributes(sudoers_dir)
292		if dirattrs == nil then
293			local r, err = mkdir_p(sudoers_dir)
294			if not r then
295				return nil, err .. " (creating " .. sudoers_dir .. ")"
296			end
297			chmodsudoersd = true
298		end
299	end
300	local f = io.open(sudoers, "a")
301	if not f then
302		warnmsg("impossible to open " .. sudoers)
303		return
304	end
305	if type(pwd.sudo) == "string" then
306		f:write(pwd.name .. " " .. pwd.sudo .. "\n")
307	elseif type(pwd.sudo) == "table" then
308		for _, str in ipairs(pwd.sudo) do
309			f:write(pwd.name .. " " .. str .. "\n")
310		end
311	end
312	f:close()
313	if chmodsudoers then
314		chmod(sudoers, "0640")
315	end
316	if chmodsudoersd then
317		chmod(sudoers, "0740")
318	end
319end
320
321local function update_sshd_config(key, value)
322	local sshd_config = "/etc/ssh/sshd_config"
323	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
324	if root then
325		sshd_config = root .. sshd_config
326	end
327	local f = assert(io.open(sshd_config, "r+"))
328	local tgt = assert(io.open(sshd_config .. ".nuageinit", "w"))
329	local found = false
330	local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$"
331	while true do
332		local line = f:read()
333		if line == nil then break end
334		local _, _, val = line:lower():find(pattern)
335		if val then
336			found = true
337			if val == value then
338				assert(tgt:write(line .. "\n"))
339			else
340				assert(tgt:write(key .. " " .. value .. "\n"))
341			end
342		else
343			assert(tgt:write(line .. "\n"))
344		end
345	end
346	if not found then
347		assert(tgt:write(key .. " " .. value .. "\n"))
348	end
349	assert(f:close())
350	assert(tgt:close())
351	os.rename(sshd_config .. ".nuageinit", sshd_config)
352end
353
354local function exec_change_password(user, password, type, expire)
355	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
356	local cmd = "pw "
357	if root then
358		cmd = cmd .. "-R " .. root .. " "
359	end
360	local postcmd = " -H 0"
361	local input = password
362	if type ~= nil and type == "text" then
363		postcmd = " -h 0"
364	else
365		if password == "RANDOM" then
366			input = nil
367			postcmd = " -w random"
368		end
369	end
370	cmd = cmd .. "usermod " .. user .. postcmd
371	if expire then
372		cmd = cmd .. " -p 1"
373	else
374		cmd = cmd .. " -p 0"
375	end
376	local f = io.popen(cmd .. " >/dev/null", "w")
377	if input then
378		f:write(input)
379	end
380	-- ignore stdout to avoid printing the password in case of random password
381	local r = f:close(cmd)
382	if not r then
383		warnmsg("fail to change user password ".. user)
384		warnmsg(cmd)
385	end
386end
387
388local function change_password_from_line(line, expire)
389	local user, password = line:match("%s*(%w+):(%S+)%s*")
390	local type = nil
391	if user and password then
392		if password == "R" then
393			password = "RANDOM"
394		end
395		if not password:match("^%$%d+%$%w+%$") then
396			if password ~= "RANDOM" then
397				type = "text"
398			end
399		end
400		exec_change_password(user, password, type, expire)
401	end
402end
403
404local function chpasswd(obj)
405	if type(obj) ~= "table" then
406		warnmsg("Invalid chpasswd entry, expecting an object")
407		return
408	end
409	local expire = false
410	if obj.expire ~= nil then
411		if type(obj.expire) == "boolean" then
412			expire = obj.expire
413		else
414			warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire))
415		end
416	end
417	if obj.users ~= nil then
418		if type(obj.users) ~= "table" then
419			warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users))
420			goto list
421		end
422		for _, u in ipairs(obj.users) do
423			if type(u) ~= "table" then
424				warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u))
425				goto next
426			end
427			if not u.name then
428				warnmsg("Invalid entry for chpasswd.users: missing 'name'")
429				goto next
430			end
431			if not u.password then
432				warnmsg("Invalid entry for chpasswd.users: missing 'password'")
433				goto next
434			end
435			exec_change_password(u.name, u.password, u.type, expire)
436			::next::
437		end
438	end
439	::list::
440	if obj.list ~= nil then
441		warnmsg("chpasswd.list is deprecated consider using chpasswd.users")
442		if type(obj.list) == "string" then
443			for line in obj.list:gmatch("[^\n]+") do
444				change_password_from_line(line, expire)
445			end
446		elseif type(obj.list) == "table" then
447			for _, u in ipairs(obj.list) do
448				change_password_from_line(u, expire)
449			end
450		end
451	end
452end
453
454local function settimezone(timezone)
455	if timezone == nil then
456		return
457	end
458	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
459	if not root then
460		root = "/"
461	end
462
463	f, _, rc = os.execute("tzsetup -s -C " .. root .. " " .. timezone)
464
465	if not f then
466		warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )")
467		return
468	end
469end
470
471local function pkg_bootstrap()
472	if os.getenv("NUAGE_RUN_TESTS") then
473		return true
474	end
475	if os.execute("pkg -N 2>/dev/null") then
476		return true
477	end
478	print("Bootstrapping pkg")
479	return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap")
480end
481
482local function install_package(package)
483	if package == nil then
484		return true
485	end
486	local install_cmd = "pkg install -y " .. package
487	local test_cmd = "pkg info -q " .. package
488	if os.getenv("NUAGE_RUN_TESTS") then
489		print(install_cmd)
490		print(test_cmd)
491		return true
492	end
493	if os.execute(test_cmd) then
494		return true
495	end
496	return os.execute(install_cmd)
497end
498
499local function run_pkg_cmd(subcmd)
500	local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd
501	if os.getenv("NUAGE_RUN_TESTS") then
502		print(cmd)
503		return true
504	end
505	return os.execute(cmd)
506end
507local function update_packages()
508	return run_pkg_cmd("update")
509end
510
511local function upgrade_packages()
512	return run_pkg_cmd("upgrade")
513end
514
515local function addfile(file, defer)
516	if type(file) ~= "table" then
517		return false, "Invalid object"
518	end
519	if defer and not file.defer then
520		return true
521	end
522	if not defer and file.defer then
523		return true
524	end
525	if not file.path then
526		return false, "No path provided for the file to write"
527	end
528	local content = nil
529	if file.content then
530		if file.encoding then
531			if file.encoding == "b64" or file.encoding == "base64" then
532				content = decode_base64(file.content)
533			else
534				return false, "Unsupported encoding: " .. file.encoding
535			end
536		else
537			content = file.content
538		end
539	end
540	local mode = "w"
541	if file.append then
542		mode = "a"
543	end
544
545	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
546	if not root then
547		root = ""
548	end
549	local filepath = root .. file.path
550	local f = assert(io.open(filepath, mode))
551	if content then
552		f:write(content)
553	end
554	f:close()
555	if file.permissions then
556		chmod(filepath, file.permissions)
557	end
558	if file.owner then
559		local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
560		if not owner then
561			owner = file.owner
562		end
563		chown(filepath, owner, group)
564	end
565	return true
566end
567
568local n = {
569	warn = warnmsg,
570	err = errmsg,
571	chmod = chmod,
572	chown = chown,
573	dirname = dirname,
574	mkdir_p = mkdir_p,
575	sethostname = sethostname,
576	settimezone = settimezone,
577	adduser = adduser,
578	addgroup = addgroup,
579	addsshkey = addsshkey,
580	update_sshd_config = update_sshd_config,
581	chpasswd = chpasswd,
582	pkg_bootstrap = pkg_bootstrap,
583	install_package = install_package,
584	update_packages = update_packages,
585	upgrade_packages = upgrade_packages,
586	addsudo = addsudo,
587	addfile = addfile
588}
589
590return n
591