xref: /freebsd/libexec/nuageinit/nuage.lua (revision ee3960cba1068e12fb032a68c46d74841d9edab3)
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 warnmsg(str, prepend)
11	if not str then
12		return
13	end
14	local tag = ""
15	if prepend ~= false then
16		tag = "nuageinit: "
17	end
18	io.stderr:write(tag .. str .. "\n")
19end
20
21local function errmsg(str, prepend)
22	warnmsg(str, prepend)
23	os.exit(1)
24end
25
26local function dirname(oldpath)
27	if not oldpath then
28		return nil
29	end
30	local path = oldpath:gsub("[^/]+/*$", "")
31	if path == "" then
32		return nil
33	end
34	return path
35end
36
37local function mkdir_p(path)
38	if lfs.attributes(path, "mode") ~= nil then
39		return true
40	end
41	local r, err = mkdir_p(dirname(path))
42	if not r then
43		return nil, err .. " (creating " .. path .. ")"
44	end
45	return lfs.mkdir(path)
46end
47
48local function sethostname(hostname)
49	if hostname == nil then
50		return
51	end
52	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
53	if not root then
54		root = ""
55	end
56	local hostnamepath = root .. "/etc/rc.conf.d/hostname"
57
58	mkdir_p(dirname(hostnamepath))
59	local f, err = io.open(hostnamepath, "w")
60	if not f then
61		warnmsg("Impossible to open " .. hostnamepath .. ":" .. err)
62		return
63	end
64	f:write('hostname="' .. hostname .. '"\n')
65	f:close()
66end
67
68local function splitlist(list)
69	local ret = {}
70	if type(list) == "string" then
71		for str in list:gmatch("([^, ]+)") do
72			ret[#ret + 1] = str
73		end
74	elseif type(list) == "table" then
75		ret = list
76	else
77		warnmsg("Invalid type " .. type(list) .. ", expecting table or string")
78	end
79	return ret
80end
81
82local function adduser(pwd)
83	if (type(pwd) ~= "table") then
84		warnmsg("Argument should be a table")
85		return nil
86	end
87	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
88	local cmd = "pw "
89	if root then
90		cmd = cmd .. "-R " .. root .. " "
91	end
92	local f = io.popen(cmd .. " usershow " .. pwd.name .. " -7 2> /dev/null")
93	local pwdstr = f:read("*a")
94	f:close()
95	if pwdstr:len() ~= 0 then
96		return pwdstr:match("%a+:.+:%d+:%d+:.*:(.*):.*")
97	end
98	if not pwd.gecos then
99		pwd.gecos = pwd.name .. " User"
100	end
101	if not pwd.homedir then
102		pwd.homedir = "/home/" .. pwd.name
103	end
104	local extraargs = ""
105	if pwd.groups then
106		local list = splitlist(pwd.groups)
107		extraargs = " -G " .. table.concat(list, ",")
108	end
109	-- pw will automatically create a group named after the username
110	-- do not add a -g option in this case
111	if pwd.primary_group and pwd.primary_group ~= pwd.name then
112		extraargs = extraargs .. " -g " .. pwd.primary_group
113	end
114	if not pwd.no_create_home then
115		extraargs = extraargs .. " -m "
116	end
117	if not pwd.shell then
118		pwd.shell = "/bin/sh"
119	end
120	local precmd = ""
121	local postcmd = ""
122	local input = nil
123	if pwd.passwd then
124		input = pwd.passwd
125		postcmd = " -H 0"
126	elseif pwd.plain_text_passwd then
127		input = pwd.plain_text_passwd
128		postcmd = " -h 0"
129	end
130	cmd = precmd .. "pw "
131	if root then
132		cmd = cmd .. "-R " .. root .. " "
133	end
134	cmd = cmd .. "useradd -n " .. pwd.name .. " -M 0755 -w none "
135	cmd = cmd .. extraargs .. " -c '" .. pwd.gecos
136	cmd = cmd .. "' -d '" .. pwd.homedir .. "' -s " .. pwd.shell .. postcmd
137
138	f = io.popen(cmd, "w")
139	if input then
140		f:write(input)
141	end
142	local r = f:close(cmd)
143	if not r then
144		warnmsg("fail to add user " .. pwd.name)
145		warnmsg(cmd)
146		return nil
147	end
148	if pwd.locked then
149		cmd = "pw "
150		if root then
151			cmd = cmd .. "-R " .. root .. " "
152		end
153		cmd = cmd .. "lock " .. pwd.name
154		os.execute(cmd)
155	end
156	return pwd.homedir
157end
158
159local function addgroup(grp)
160	if (type(grp) ~= "table") then
161		warnmsg("Argument should be a table")
162		return false
163	end
164	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
165	local cmd = "pw "
166	if root then
167		cmd = cmd .. "-R " .. root .. " "
168	end
169	local f = io.popen(cmd .. " groupshow " .. grp.name .. " 2> /dev/null")
170	local grpstr = f:read("*a")
171	f:close()
172	if grpstr:len() ~= 0 then
173		return true
174	end
175	local extraargs = ""
176	if grp.members then
177		local list = splitlist(grp.members)
178		extraargs = " -M " .. table.concat(list, ",")
179	end
180	cmd = "pw "
181	if root then
182		cmd = cmd .. "-R " .. root .. " "
183	end
184	cmd = cmd .. "groupadd -n " .. grp.name .. extraargs
185	local r = os.execute(cmd)
186	if not r then
187		warnmsg("fail to add group " .. grp.name)
188		warnmsg(cmd)
189		return false
190	end
191	return true
192end
193
194local function addsshkey(homedir, key)
195	local chownak = false
196	local chowndotssh = false
197	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
198	if root then
199		homedir = root .. "/" .. homedir
200	end
201	local ak_path = homedir .. "/.ssh/authorized_keys"
202	local dotssh_path = homedir .. "/.ssh"
203	local dirattrs = lfs.attributes(ak_path)
204	if dirattrs == nil then
205		chownak = true
206		dirattrs = lfs.attributes(dotssh_path)
207		if dirattrs == nil then
208			assert(lfs.mkdir(dotssh_path))
209			chowndotssh = true
210			dirattrs = lfs.attributes(homedir)
211		end
212	end
213
214	local f = io.open(ak_path, "a")
215	if not f then
216		warnmsg("impossible to open " .. ak_path)
217		return
218	end
219	f:write(key .. "\n")
220	f:close()
221	if chownak then
222		sys_stat.chmod(ak_path, 384)
223		unistd.chown(ak_path, dirattrs.uid, dirattrs.gid)
224	end
225	if chowndotssh then
226		sys_stat.chmod(dotssh_path, 448)
227		unistd.chown(dotssh_path, dirattrs.uid, dirattrs.gid)
228	end
229end
230
231local function addsudo(pwd)
232	local chmodsudoersd = false
233	local chmodsudoers = false
234	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
235	local sudoers_dir = "/usr/local/etc/sudoers.d"
236	if root then
237		sudoers_dir= root .. sudoers_dir
238	end
239	local sudoers = sudoers_dir .. "/90-nuageinit-users"
240	local sudoers_attr = lfs.attributes(sudoers)
241	if sudoers_attr == nil then
242		chmodsudoers = true
243		local dirattrs = lfs.attributes(sudoers_dir)
244		if dirattrs == nil then
245			local r, err = mkdir_p(sudoers_dir)
246			if not r then
247				return nil, err .. " (creating " .. sudoers_dir .. ")"
248			end
249			chmodsudoersd = true
250		end
251	end
252	local f = io.open(sudoers, "a")
253	if not f then
254		warnmsg("impossible to open " .. sudoers)
255		return
256	end
257	f:write(pwd.name .. " " .. pwd.sudo .. "\n")
258	f:close()
259	if chmodsudoers then
260		sys_stat.chmod(sudoers, 416)
261	end
262	if chmodsudoersd then
263		sys_stat.chmod(sudoers, 480)
264	end
265end
266
267local function update_sshd_config(key, value)
268	local sshd_config = "/etc/ssh/sshd_config"
269	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
270	if root then
271		sshd_config = root .. sshd_config
272	end
273	local f = assert(io.open(sshd_config, "r+"))
274	local tgt = assert(io.open(sshd_config .. ".nuageinit", "w"))
275	local found = false
276	local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$"
277	while true do
278		local line = f:read()
279		if line == nil then break end
280		local _, _, val = line:lower():find(pattern)
281		if val then
282			found = true
283			if val == value then
284				assert(tgt:write(line .. "\n"))
285			else
286				assert(tgt:write(key .. " " .. value .. "\n"))
287			end
288		else
289			assert(tgt:write(line .. "\n"))
290		end
291	end
292	if not found then
293		assert(tgt:write(key .. " " .. value .. "\n"))
294	end
295	assert(f:close())
296	assert(tgt:close())
297	os.rename(sshd_config .. ".nuageinit", sshd_config)
298end
299
300local function exec_change_password(user, password, type, expire)
301	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
302	local cmd = "pw "
303	if root then
304		cmd = cmd .. "-R " .. root .. " "
305	end
306	local postcmd = " -H 0"
307	local input = password
308	if type ~= nil and type == "text" then
309		postcmd = " -h 0"
310	else
311		if password == "RANDOM" then
312			input = nil
313			postcmd = " -w random"
314		end
315	end
316	cmd = cmd .. "usermod " .. user .. postcmd
317	if expire then
318		cmd = cmd .. " -p 1"
319	else
320		cmd = cmd .. " -p 0"
321	end
322	local f = io.popen(cmd .. " >/dev/null", "w")
323	if input then
324		f:write(input)
325	end
326	-- ignore stdout to avoid printing the password in case of random password
327	local r = f:close(cmd)
328	if not r then
329		warnmsg("fail to change user password ".. user)
330		warnmsg(cmd)
331	end
332end
333
334local function change_password_from_line(line, expire)
335	local user, password = line:match("%s*(%w+):(%S+)%s*")
336	local type = nil
337	if user and password then
338		if password == "R" then
339			password = "RANDOM"
340		end
341		if not password:match("^%$%d+%$%w+%$") then
342			if password ~= "RANDOM" then
343				type = "text"
344			end
345		end
346		exec_change_password(user, password, type, expire)
347	end
348end
349
350local function chpasswd(obj)
351	if type(obj) ~= "table" then
352		warnmsg("Invalid chpasswd entry, expecting an object")
353		return
354	end
355	local expire = false
356	if obj.expire ~= nil then
357		if type(obj.expire) == "boolean" then
358			expire = obj.expire
359		else
360			warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire))
361		end
362	end
363	if obj.users ~= nil then
364		if type(obj.users) ~= "table" then
365			warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users))
366			goto list
367		end
368		for _, u in ipairs(obj.users) do
369			if type(u) ~= "table" then
370				warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u))
371				goto next
372			end
373			if not u.name then
374				warnmsg("Invalid entry for chpasswd.users: missing 'name'")
375				goto next
376			end
377			if not u.password then
378				warnmsg("Invalid entry for chpasswd.users: missing 'password'")
379				goto next
380			end
381			exec_change_password(u.name, u.password, u.type, expire)
382			::next::
383		end
384	end
385	::list::
386	if obj.list ~= nil then
387		warnmsg("chpasswd.list is deprecated consider using chpasswd.users")
388		if type(obj.list) == "string" then
389			for line in obj.list:gmatch("[^\n]+") do
390				change_password_from_line(line, expire)
391			end
392		elseif type(obj.list) == "table" then
393			for _, u in ipairs(obj.list) do
394				change_password_from_line(u, expire)
395			end
396		end
397	end
398end
399
400local function pkg_bootstrap()
401	if os.getenv("NUAGE_RUN_TESTS") then
402		return true
403	end
404	if os.execute("pkg -N 2>/dev/null") then
405		return true
406	end
407	print("Bootstrapping pkg")
408	return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap")
409end
410
411local function install_package(package)
412	if package == nil then
413		return true
414	end
415	local install_cmd = "pkg install -y " .. package
416	local test_cmd = "pkg info -q " .. package
417	if os.getenv("NUAGE_RUN_TESTS") then
418		print(install_cmd)
419		print(test_cmd)
420		return true
421	end
422	if os.execute(test_cmd) then
423		return true
424	end
425	return os.execute(install_cmd)
426end
427
428local function run_pkg_cmd(subcmd)
429	local cmd = "pkg " .. subcmd .. " -y"
430	if os.getenv("NUAGE_RUN_TESTS") then
431		print(cmd)
432		return true
433	end
434	return os.execute(cmd)
435end
436local function update_packages()
437	return run_pkg_cmd("update")
438end
439
440local function upgrade_packages()
441	return run_pkg_cmd("upgrade")
442end
443
444local n = {
445	warn = warnmsg,
446	err = errmsg,
447	dirname = dirname,
448	mkdir_p = mkdir_p,
449	sethostname = sethostname,
450	adduser = adduser,
451	addgroup = addgroup,
452	addsshkey = addsshkey,
453	update_sshd_config = update_sshd_config,
454	chpasswd = chpasswd,
455	pkg_bootstrap = pkg_bootstrap,
456	install_package = install_package,
457	update_packages = update_packages,
458	upgrade_packages = upgrade_packages,
459	addsudo = addsudo
460}
461
462return n
463