xref: /freebsd/libexec/nuageinit/nuage.lua (revision 667ef8875bad115d334a85c1023db0cf4d8379ba)
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 pkg_bootstrap()
455	if os.getenv("NUAGE_RUN_TESTS") then
456		return true
457	end
458	if os.execute("pkg -N 2>/dev/null") then
459		return true
460	end
461	print("Bootstrapping pkg")
462	return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap")
463end
464
465local function install_package(package)
466	if package == nil then
467		return true
468	end
469	local install_cmd = "pkg install -y " .. package
470	local test_cmd = "pkg info -q " .. package
471	if os.getenv("NUAGE_RUN_TESTS") then
472		print(install_cmd)
473		print(test_cmd)
474		return true
475	end
476	if os.execute(test_cmd) then
477		return true
478	end
479	return os.execute(install_cmd)
480end
481
482local function run_pkg_cmd(subcmd)
483	local cmd = "pkg " .. subcmd .. " -y"
484	if os.getenv("NUAGE_RUN_TESTS") then
485		print(cmd)
486		return true
487	end
488	return os.execute(cmd)
489end
490local function update_packages()
491	return run_pkg_cmd("update")
492end
493
494local function upgrade_packages()
495	return run_pkg_cmd("upgrade")
496end
497
498local function addfile(file, defer)
499	if type(file) ~= "table" then
500		return false, "Invalid object"
501	end
502	if defer and not file.defer then
503		return true
504	end
505	if not defer and file.defer then
506		return true
507	end
508	if not file.path then
509		return false, "No path provided for the file to write"
510	end
511	local content = nil
512	if file.content then
513		if file.encoding then
514			if file.encoding == "b64" or file.encoding == "base64" then
515				content = decode_base64(file.content)
516			else
517				return false, "Unsupported encoding: " .. file.encoding
518			end
519		else
520			content = file.content
521		end
522	end
523	local mode = "w"
524	if file.append then
525		mode = "a"
526	end
527
528	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
529	if not root then
530		root = ""
531	end
532	local filepath = root .. file.path
533	local f = assert(io.open(filepath, mode))
534	if content then
535		f:write(content)
536	end
537	f:close()
538	if file.permissions then
539		chmod(filepath, file.permissions)
540	end
541	if file.owner then
542		local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
543		if not owner then
544			owner = file.owner
545		end
546		chown(filepath, owner, group)
547	end
548	return true
549end
550
551local n = {
552	warn = warnmsg,
553	err = errmsg,
554	chmod = chmod,
555	chown = chown,
556	dirname = dirname,
557	mkdir_p = mkdir_p,
558	sethostname = sethostname,
559	adduser = adduser,
560	addgroup = addgroup,
561	addsshkey = addsshkey,
562	update_sshd_config = update_sshd_config,
563	chpasswd = chpasswd,
564	pkg_bootstrap = pkg_bootstrap,
565	install_package = install_package,
566	update_packages = update_packages,
567	upgrade_packages = upgrade_packages,
568	addsudo = addsudo,
569	addfile = addfile
570}
571
572return n
573