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