xref: /freebsd/libexec/nuageinit/nuage.lua (revision c27f7d6b9cf6d4ab01cb3d0972726c14e0aca146)
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 update_sshd_config(key, value)
232	local sshd_config = "/etc/ssh/sshd_config"
233	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
234	if root then
235		sshd_config = root .. sshd_config
236	end
237	local f = assert(io.open(sshd_config, "r+"))
238	local tgt = assert(io.open(sshd_config .. ".nuageinit", "w"))
239	local found = false
240	local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$"
241	while true do
242		local line = f:read()
243		if line == nil then break end
244		local _, _, val = line:lower():find(pattern)
245		if val then
246			found = true
247			if val == value then
248				assert(tgt:write(line .. "\n"))
249			else
250				assert(tgt:write(key .. " " .. value .. "\n"))
251			end
252		else
253			assert(tgt:write(line .. "\n"))
254		end
255	end
256	if not found then
257		assert(tgt:write(key .. " " .. value .. "\n"))
258	end
259	assert(f:close())
260	assert(tgt:close())
261	os.rename(sshd_config .. ".nuageinit", sshd_config)
262end
263
264local function exec_change_password(user, password, type, expire)
265	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
266	local cmd = "pw "
267	if root then
268		cmd = cmd .. "-R " .. root .. " "
269	end
270	local postcmd = " -H 0"
271	local input = password
272	if type ~= nil and type == "text" then
273		postcmd = " -h 0"
274	else
275		if password == "RANDOM" then
276			input = nil
277			postcmd = " -w random"
278		end
279	end
280	cmd = cmd .. "usermod " .. user .. postcmd
281	if expire then
282		cmd = cmd .. " -p 1"
283	else
284		cmd = cmd .. " -p 0"
285	end
286	local f = io.popen(cmd .. " >/dev/null", "w")
287	if input then
288		f:write(input)
289	end
290	-- ignore stdout to avoid printing the password in case of random password
291	local r = f:close(cmd)
292	if not r then
293		warnmsg("fail to change user password ".. user)
294		warnmsg(cmd)
295	end
296end
297
298local function change_password_from_line(line, expire)
299	local user, password = line:match("%s*(%w+):(%S+)%s*")
300	local type = nil
301	if user and password then
302		if password == "R" then
303			password = "RANDOM"
304		end
305		if not password:match("^%$%d+%$%w+%$") then
306			if password ~= "RANDOM" then
307				type = "text"
308			end
309		end
310		exec_change_password(user, password, type, expire)
311	end
312end
313
314local function chpasswd(obj)
315	if type(obj) ~= "table" then
316		warnmsg("Invalid chpasswd entry, expecting an object")
317		return
318	end
319	local expire = false
320	if obj.expire ~= nil then
321		if type(obj.expire) == "boolean" then
322			expire = obj.expire
323		else
324			warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire))
325		end
326	end
327	if obj.users ~= nil then
328		if type(obj.users) ~= "table" then
329			warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users))
330			goto list
331		end
332		for _, u in ipairs(obj.users) do
333			if type(u) ~= "table" then
334				warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u))
335				goto next
336			end
337			if not u.name then
338				warnmsg("Invalid entry for chpasswd.users: missing 'name'")
339				goto next
340			end
341			if not u.password then
342				warnmsg("Invalid entry for chpasswd.users: missing 'password'")
343				goto next
344			end
345			exec_change_password(u.name, u.password, u.type, expire)
346			::next::
347		end
348	end
349	::list::
350	if obj.list ~= nil then
351		warnmsg("chpasswd.list is deprecated consider using chpasswd.users")
352		if type(obj.list) == "string" then
353			for line in obj.list:gmatch("[^\n]+") do
354				change_password_from_line(line, expire)
355			end
356		elseif type(obj.list) == "table" then
357			for _, u in ipairs(obj.list) do
358				change_password_from_line(u, expire)
359			end
360		end
361	end
362end
363
364local n = {
365	warn = warnmsg,
366	err = errmsg,
367	dirname = dirname,
368	mkdir_p = mkdir_p,
369	sethostname = sethostname,
370	adduser = adduser,
371	addgroup = addgroup,
372	addsshkey = addsshkey,
373	update_sshd_config = update_sshd_config,
374	chpasswd = chpasswd
375}
376
377return n
378