xref: /freebsd/libexec/nuageinit/nuage.lua (revision 68e60bb8b6c968fe615f81ccc8afd2a30ff78003)
1---
2-- SPDX-License-Identifier: BSD-2-Clause
3--
4-- Copyright(c) 2022-2025 Baptiste Daroussin <bapt@FreeBSD.org>
5-- Copyright(c) 2025 Jesús Daniel Colmenares Oviedo <dtxdf@FreeBSD.org>
6
7local unistd = require("posix.unistd")
8local sys_stat = require("posix.sys.stat")
9local lfs = require("lfs")
10
11local function getlocalbase()
12	local f = io.popen("sysctl -in user.localbase 2> /dev/null")
13	local localbase = f:read("*l")
14	f:close()
15	if localbase == nil or localbase:len() == 0 then
16		-- fallback
17		localbase = "/usr/local"
18	end
19	return localbase
20end
21
22local function decode_base64(input)
23	if input == nil or #input == 0 then
24		return ""
25	end
26	local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
27	input = string.gsub(input, '[^'..b..'=]', '')
28
29	local result = {}
30	local bits = ''
31
32	-- convert all characters in bits
33	for i = 1, #input do
34		local x = input:sub(i, i)
35		if x == '=' then
36			break
37		end
38		local f = b:find(x) - 1
39		for j = 6, 1, -1 do
40			bits = bits .. (f % 2^j - f % 2^(j-1) > 0 and '1' or '0')
41		end
42	end
43
44	for i = 1, #bits, 8 do
45		local byte = bits:sub(i, i + 7)
46		if #byte == 8 then
47			local c = 0
48			for j = 1, 8 do
49				c = c + (byte:sub(j, j) == '1' and 2^(8 - j) or 0)
50			end
51			table.insert(result, string.char(c))
52		end
53	end
54
55	return table.concat(result)
56end
57
58local function encode_base64(input)
59	if input == nil or #input == 0 then
60		return ""
61	end
62	local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
63	local result = {}
64	local pos = 1
65	local padding = ""
66	while pos <= #input do
67		local a = string.byte(input, pos)
68		local bb = pos + 1 <= #input and string.byte(input, pos + 1) or 0
69		local c = pos + 2 <= #input and string.byte(input, pos + 2) or 0
70		table.insert(result, string.sub(b, math.floor(a / 4) + 1, math.floor(a / 4) + 1))
71		table.insert(result, string.sub(b, math.floor(a % 4 * 16 + bb / 16) + 1, math.floor(a % 4 * 16 + bb / 16) + 1))
72		if pos + 1 <= #input then
73			table.insert(result, string.sub(b, math.floor(bb % 16 * 4 + c / 64) + 1, math.floor(bb % 16 * 4 + c / 64) + 1))
74		else
75			table.insert(result, "=")
76		end
77		if pos + 2 <= #input then
78			table.insert(result, string.sub(b, math.floor(c % 64) + 1, math.floor(c % 64) + 1))
79		else
80			table.insert(result, "=")
81		end
82		pos = pos + 3
83	end
84	return table.concat(result)
85end
86
87local function shell_escape(s)
88	return "'" .. string.gsub(s, "'", "'\\''") .. "'"
89end
90
91local function warnmsg(str, prepend)
92	if not str then
93		return
94	end
95	local tag = ""
96	if prepend ~= false then
97		tag = "nuageinit: "
98	end
99	io.stderr:write(tag .. str .. "\n")
100end
101
102local function errmsg(str, prepend)
103	warnmsg(str, prepend)
104	os.exit(1)
105end
106
107local function chmod(path, mode)
108	mode = tonumber(mode, 8)
109	local _, err, msg = sys_stat.chmod(path, mode)
110	if err then
111		errmsg("chmod(" .. path .. ", " .. mode .. ") failed: " .. msg)
112	end
113end
114
115local function chown(path, owner, group)
116	local _, err, msg = unistd.chown(path, owner, group)
117	if err then
118		errmsg("chown(" .. path .. ", " .. owner .. ", " .. group .. ") failed: " .. msg)
119	end
120end
121
122local function dirname(oldpath)
123	if not oldpath then
124		return nil
125	end
126	local path = oldpath:gsub("[^/]+/*$", "")
127	if path == "" then
128		if oldpath:sub(1, 1) == "/" then
129			return "/"
130		end
131		return nil
132	end
133	return path
134end
135
136local function mkdir_p(path)
137	if lfs.attributes(path, "mode") ~= nil then
138		return true
139	end
140	local r, err = mkdir_p(dirname(path))
141	if not r then
142		return nil, err .. " (creating " .. path .. ")"
143	end
144	return lfs.mkdir(path)
145end
146
147local function sethostname(hostname)
148	if hostname == nil then
149		return
150	end
151	-- Basic hostname validation (RFC 952/1123)
152	if #hostname == 0 then
153		warnmsg("hostname is empty, ignoring")
154		return
155	end
156	if #hostname > 253 then
157		warnmsg("hostname too long (" .. #hostname .. " > 253), ignoring")
158		return
159	end
160	if hostname:match("[^a-zA-Z0-9%.%-]") then
161		warnmsg("hostname contains invalid characters: " .. hostname)
162		return
163	end
164	if hostname:match("^[%.%-]") or hostname:match("[%.%-]$") then
165		warnmsg("hostname must not start or end with a dot or hyphen: " .. hostname)
166		return
167	end
168	for label in hostname:gmatch("[^.]+") do
169		if #label > 63 then
170			warnmsg("hostname label too long (" .. #label .. " > 63): " .. label)
171			return
172		end
173		if label:match("^-") or label:match("-$") then
174			warnmsg("hostname label starts or ends with hyphen: " .. label)
175			return
176		end
177	end
178	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
179	if not root then
180		root = ""
181	end
182	local hostnamepath = root .. "/etc/rc.conf.d/hostname"
183
184	mkdir_p(dirname(hostnamepath))
185	local f, err = io.open(hostnamepath, "w")
186	if not f then
187		warnmsg("Impossible to open " .. hostnamepath .. ":" .. err)
188		return
189	end
190	f:write('hostname="' .. hostname:gsub('"', '\\"') .. '"\n')
191	f:close()
192end
193
194local function update_etc_hosts(root, hostname)
195	if hostname == nil or hostname == "" then
196		return
197	end
198	local hosts_path = root .. "/etc/hosts"
199	local lines = {}
200	local already_present = false
201
202	local f = io.open(hosts_path, "r")
203	if not f then
204		-- File doesn't exist, create a minimal one
205		local nf = io.open(hosts_path, "w")
206		if not nf then
207			warnmsg("unable to create " .. hosts_path)
208			return
209		end
210		nf:write("::1\t\tlocalhost " .. hostname .. "\n")
211		nf:write("127.0.0.1\t\tlocalhost " .. hostname .. "\n")
212		nf:close()
213		return
214	end
215
216	for line in f:lines() do
217		if line:find(hostname, 1, true) then
218			already_present = true
219		end
220		table.insert(lines, line)
221	end
222	f:close()
223
224	if already_present then
225		return
226	end
227
228	-- Not present, append to localhost lines
229	local new_lines = {}
230	local found_localhost = false
231	for _, line in ipairs(lines) do
232		if (line:match("^127%.0%.0%.1%s") or line:match("^::1%s")) and line:find("localhost", 1, true) then
233			table.insert(new_lines, line .. " " .. hostname)
234			found_localhost = true
235		else
236			table.insert(new_lines, line)
237		end
238	end
239
240	if not found_localhost then
241		table.insert(new_lines, "127.0.0.1\t\tlocalhost " .. hostname)
242	end
243
244	f = io.open(hosts_path, "w")
245	if not f then
246		warnmsg("unable to open " .. hosts_path .. " for writing")
247		return
248	end
249	for _, line in ipairs(new_lines) do
250		f:write(line .. "\n")
251	end
252	f:close()
253end
254
255local function splitlist(list)
256	local ret = {}
257	if type(list) == "string" then
258		for str in list:gmatch("([^, ]+)") do
259			ret[#ret + 1] = str
260		end
261	elseif type(list) == "table" then
262		ret = list
263	else
264		warnmsg("Invalid type " .. type(list) .. ", expecting table or string")
265	end
266	return ret
267end
268
269local function splitlines(s)
270	local ret = {}
271
272	for line in string.gmatch(s, "[^\n]+") do
273		ret[#ret + 1] = line
274	end
275
276	return ret
277end
278
279local function getgroups()
280	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
281	local cmd = "pw "
282	if root then
283		cmd = cmd .. "-R " .. root .. " "
284	end
285
286	local f = io.popen(cmd .. "groupshow -a 2> /dev/null | cut -d: -f1")
287	local groups = f:read("*a")
288	f:close()
289
290	return splitlines(groups)
291end
292
293local function purge_group(groups)
294	local existing = getgroups()
295	local ret = {}
296
297	for _, group in ipairs(groups) do
298		local found = false
299		for _, eg in ipairs(existing) do
300			if group == eg then
301				found = true
302				break
303			end
304		end
305		if found then
306			ret[#ret + 1] = group
307		else
308			warnmsg("ignoring non-existent group '" .. group .. "'")
309		end
310	end
311
312	return ret
313end
314
315local function adduser(pwd)
316	if (type(pwd) ~= "table") then
317		warnmsg("Argument should be a table")
318		return nil
319	end
320	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
321	local cmd = "pw "
322	if root then
323		cmd = cmd .. "-R " .. root .. " "
324	end
325	local f = io.popen(cmd .. " usershow " .. shell_escape(pwd.name) .. " -7 2> /dev/null")
326	local pwdstr = f:read("*a")
327	f:close()
328	if pwdstr:len() ~= 0 then
329		return pwdstr:match("%a+:.+:%d+:%d+:.*:(.*):.*")
330	end
331	if not pwd.gecos then
332		pwd.gecos = pwd.name .. " User"
333	end
334	if not pwd.homedir then
335		pwd.homedir = "/home/" .. pwd.name
336	end
337	local extraargs = ""
338	if pwd.groups then
339		local list = splitlist(pwd.groups)
340		-- pw complains if the group does not exist, so if the user
341		-- specifies one that cannot be found, nuageinit will generate
342		-- an exception and exit, unlike cloud-init, which only issues
343		-- a warning but creates the user anyway.
344		list = purge_group(list)
345		if #list > 0 then
346			local escaped_list = {}
347			for _, g in ipairs(list) do
348				table.insert(escaped_list, shell_escape(g))
349			end
350			extraargs = " -G " .. table.concat(escaped_list, ",")
351		end
352	end
353	-- pw will automatically create a group named after the username
354	-- do not add a -g option in this case
355	if pwd.primary_group and pwd.primary_group ~= pwd.name then
356		extraargs = extraargs .. " -g " .. shell_escape(pwd.primary_group)
357	end
358	if not pwd.no_create_home then
359		extraargs = extraargs .. " -m "
360	end
361	if not pwd.shell then
362		pwd.shell = "/bin/sh"
363	end
364	local postcmd = ""
365	local input = nil
366	if pwd.passwd then
367		input = pwd.passwd
368		postcmd = " -H 0"
369	elseif pwd.plain_text_passwd then
370		input = pwd.plain_text_passwd
371		postcmd = " -h 0"
372	end
373	cmd = "pw "
374	if root then
375		cmd = cmd .. "-R " .. root .. " "
376	end
377	cmd = cmd .. "useradd -n " .. shell_escape(pwd.name) .. " -M 0755 -w none "
378	cmd = cmd .. extraargs .. " -c " .. shell_escape(pwd.gecos)
379	cmd = cmd .. " -d " .. shell_escape(pwd.homedir) .. " -s " .. shell_escape(pwd.shell) .. postcmd
380
381	f = io.popen(cmd, "w")
382	if input then
383		f:write(input)
384	end
385	local r = f:close()
386	if not r then
387		warnmsg("fail to add user " .. pwd.name)
388		warnmsg(cmd)
389		return nil
390	end
391	if pwd.locked then
392		cmd = "pw "
393		if root then
394			cmd = cmd .. "-R " .. root .. " "
395		end
396		cmd = cmd .. "lock " .. shell_escape(pwd.name)
397		os.execute(cmd)
398	end
399	return pwd.homedir
400end
401
402local function addgroup(grp)
403	if (type(grp) ~= "table") then
404		warnmsg("Argument should be a table")
405		return false
406	end
407	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
408	local cmd = "pw "
409	if root then
410		cmd = cmd .. "-R " .. root .. " "
411	end
412	local f = io.popen(cmd .. " groupshow " .. shell_escape(grp.name) .. " 2> /dev/null")
413	local grpstr = f:read("*a")
414	f:close()
415	if grpstr:len() ~= 0 then
416		return true
417	end
418	local extraargs = ""
419	if grp.members then
420		local list = splitlist(grp.members)
421		local escaped_list = {}
422		for _, m in ipairs(list) do
423			table.insert(escaped_list, shell_escape(m))
424		end
425		extraargs = " -M " .. table.concat(escaped_list, ",")
426	end
427	cmd = "pw "
428	if root then
429		cmd = cmd .. "-R " .. root .. " "
430	end
431	cmd = cmd .. "groupadd -n " .. shell_escape(grp.name) .. extraargs
432	local r = os.execute(cmd)
433	if not r then
434		warnmsg("fail to add group " .. grp.name)
435		warnmsg(cmd)
436		return false
437	end
438	return true
439end
440
441local function addsshkey(homedir, key)
442	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
443	if root then
444		homedir = root .. "/" .. homedir
445	end
446	local ak_path = homedir .. "/.ssh/authorized_keys"
447	local dotssh_path = homedir .. "/.ssh"
448
449	-- Check what already exists before creating anything
450	local ak_exists = lfs.attributes(ak_path) ~= nil
451	local dotssh_exists = lfs.attributes(dotssh_path) ~= nil
452
453	-- Ensure .ssh directory exists
454	if not dotssh_exists then
455		local r, err = mkdir_p(dotssh_path)
456		if not r then
457			warnmsg("cannot create " .. dotssh_path .. ": " .. err)
458			return
459		end
460	end
461
462	-- Get homedir attributes for ownership
463	local dirattrs = lfs.attributes(homedir)
464	if not dirattrs then
465		warnmsg("cannot get attributes for " .. homedir)
466		return
467	end
468
469	local f = io.open(ak_path, "a")
470	if not f then
471		warnmsg("impossible to open " .. ak_path)
472		return
473	end
474	f:write(key .. "\n")
475	f:close()
476
477	-- Set permissions and ownership on newly created files/dirs
478	if not ak_exists then
479		chmod(ak_path, "0600")
480		chown(ak_path, dirattrs.uid, dirattrs.gid)
481	end
482	if not dotssh_exists then
483		chmod(dotssh_path, "0700")
484		chown(dotssh_path, dirattrs.uid, dirattrs.gid)
485	end
486end
487
488local function adddoas(pwd)
489	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
490	local localbase = getlocalbase()
491	local etcdir = localbase .. "/etc"
492	if root then
493		etcdir= root .. etcdir
494	end
495	local doasconf = etcdir .. "/doas.conf"
496
497	local doasconf_exists = lfs.attributes(doasconf) ~= nil
498	local etcdir_exists = lfs.attributes(etcdir) ~= nil
499
500	-- Ensure etc directory exists
501	if not etcdir_exists then
502		local r, err = mkdir_p(etcdir)
503		if not r then
504			warnmsg("cannot create " .. etcdir .. ": " .. err)
505			return
506		end
507	end
508
509	local f = io.open(doasconf, "a")
510	if not f then
511		warnmsg("impossible to open " .. doasconf)
512		return
513	end
514	if type(pwd.doas) == "string" then
515		local rule = pwd.doas
516		rule = rule:gsub("%%u", pwd.name)
517		f:write(rule .. "\n")
518	elseif type(pwd.doas) == "table" then
519		for _, str in ipairs(pwd.doas) do
520			local rule = str
521			rule = rule:gsub("%%u", pwd.name)
522			f:write(rule .. "\n")
523		end
524	end
525	f:close()
526
527	-- Set permissions on newly created files/dirs
528	if not doasconf_exists then
529		chmod(doasconf, "0640")
530	end
531	if not etcdir_exists then
532		chmod(etcdir, "0755")
533	end
534end
535
536local function addsudo(pwd)
537	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
538	local localbase = getlocalbase()
539	local sudoers_dir = localbase .. "/etc/sudoers.d"
540	if root then
541		sudoers_dir= root .. sudoers_dir
542	end
543	local sudoers = sudoers_dir .. "/90-nuageinit-users"
544
545	local sudoers_exists = lfs.attributes(sudoers) ~= nil
546	local sudoers_dir_exists = lfs.attributes(sudoers_dir) ~= nil
547
548	-- Ensure sudoers.d directory exists
549	if not sudoers_dir_exists then
550		local r, err = mkdir_p(sudoers_dir)
551		if not r then
552			warnmsg("cannot create " .. sudoers_dir .. ": " .. err)
553			return
554		end
555	end
556
557	local f = io.open(sudoers, "a")
558	if not f then
559		warnmsg("impossible to open " .. sudoers)
560		return
561	end
562	if type(pwd.sudo) == "string" then
563		f:write(pwd.name .. " " .. pwd.sudo .. "\n")
564	elseif type(pwd.sudo) == "table" then
565		for _, str in ipairs(pwd.sudo) do
566			f:write(pwd.name .. " " .. str .. "\n")
567		end
568	end
569	f:close()
570
571	-- Set permissions on newly created files/dirs
572	if not sudoers_exists then
573		chmod(sudoers, "0440")
574	end
575	if not sudoers_dir_exists then
576		chmod(sudoers_dir, "0750")
577	end
578end
579
580local function update_sshd_config(key, value)
581	local sshd_config = "/etc/ssh/sshd_config"
582	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
583	if root then
584		sshd_config = root .. sshd_config
585	end
586	local f = io.open(sshd_config, "r")
587	if not f then
588		-- File does not exist, create it with the given key/value
589		f = io.open(sshd_config, "w")
590		if not f then
591			warnmsg("Unable to open " .. sshd_config .. " for writing")
592			return
593		end
594		f:write(key .. " " .. value .. "\n")
595		f:close()
596		return
597	end
598	-- Read existing content
599	local lines = {}
600	local found = false
601	local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$"
602	for line in f:lines() do
603		local _, _, val = line:lower():find(pattern)
604		if val then
605			found = true
606			if val ~= value then
607				table.insert(lines, key .. " " .. value)
608			else
609				table.insert(lines, line)
610			end
611		else
612			table.insert(lines, line)
613		end
614	end
615	f:close()
616	if not found then
617		table.insert(lines, key .. " " .. value)
618	end
619	-- Write back
620	f = io.open(sshd_config .. ".nuageinit", "w")
621	if not f then
622		warnmsg("Unable to open " .. sshd_config .. ".nuageinit for writing")
623		return
624	end
625	for _, l in ipairs(lines) do
626		f:write(l .. "\n")
627	end
628	f:close()
629	os.rename(sshd_config .. ".nuageinit", sshd_config)
630end
631
632local function delete_ssh_host_keys(root)
633	local ssh_dir = root .. "/etc/ssh"
634	local attrs = lfs.attributes(ssh_dir)
635	if not attrs or attrs.mode ~= "directory" then
636		return
637	end
638	for entry in lfs.dir(ssh_dir) do
639		if entry:match("^ssh_host_.*key") or entry:match("^ssh_host_.*key%.pub") then
640			os.remove(ssh_dir .. "/" .. entry)
641		end
642	end
643end
644
645local function exec_change_password(user, password, type, expire)
646	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
647	local cmd = "pw "
648	if root then
649		cmd = cmd .. "-R " .. root .. " "
650	end
651	local postcmd = " -H 0"
652	local input = password
653	if type ~= nil and type == "text" then
654		postcmd = " -h 0"
655	else
656		if password == "RANDOM" then
657			input = nil
658			postcmd = " -w random"
659		end
660	end
661	cmd = cmd .. "usermod " .. shell_escape(user) .. postcmd
662	if expire then
663		cmd = cmd .. " -p 1"
664	else
665		cmd = cmd .. " -p 0"
666	end
667	local f = io.popen(cmd .. " >/dev/null", "w")
668	if input then
669		f:write(input)
670	end
671	-- ignore stdout to avoid printing the password in case of random password
672	local r = f:close()
673	if not r then
674		warnmsg("fail to change user password ".. user)
675		warnmsg(cmd)
676	end
677end
678
679local function change_password_from_line(line, expire)
680	local user, password = line:match("%s*(%w+):(%S+)%s*")
681	local type = nil
682	if user and password then
683		if password == "R" then
684			password = "RANDOM"
685		end
686		if not password:match("^%$%d+%$%w+%$") then
687			if password ~= "RANDOM" then
688				type = "text"
689			end
690		end
691		exec_change_password(user, password, type, expire)
692	end
693end
694
695local function chpasswd(obj)
696	if type(obj) ~= "table" then
697		warnmsg("Invalid chpasswd entry, expecting an object")
698		return
699	end
700	local expire = false
701	if obj.expire ~= nil then
702		if type(obj.expire) == "boolean" then
703			expire = obj.expire
704		else
705			warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire))
706		end
707	end
708	if obj.users ~= nil then
709		if type(obj.users) ~= "table" then
710			warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users))
711		else
712			for _, u in ipairs(obj.users) do
713				if type(u) ~= "table" then
714					warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u))
715				elseif not u.name then
716					warnmsg("Invalid entry for chpasswd.users: missing 'name'")
717				elseif not u.password then
718					warnmsg("Invalid entry for chpasswd.users: missing 'password'")
719				else
720					exec_change_password(u.name, u.password, u.type, expire)
721				end
722			end
723		end
724	end
725	if obj.list ~= nil then
726		warnmsg("chpasswd.list is deprecated consider using chpasswd.users")
727		if type(obj.list) == "string" then
728			for line in obj.list:gmatch("[^\n]+") do
729				change_password_from_line(line, expire)
730			end
731		elseif type(obj.list) == "table" then
732			for _, u in ipairs(obj.list) do
733				change_password_from_line(u, expire)
734			end
735		end
736	end
737end
738
739local function settimezone(timezone)
740	if timezone == nil then
741		return
742	end
743	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
744	if not root then
745		root = "/"
746	end
747
748	local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone))
749
750	if not f then
751		warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )")
752		return
753	end
754end
755
756local function pkg_bootstrap()
757	if os.getenv("NUAGE_RUN_TESTS") then
758		return true
759	end
760	if os.execute("pkg -N 2>/dev/null") then
761		return true
762	end
763	print("Bootstrapping pkg")
764	return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap")
765end
766
767local function install_package(package)
768	if package == nil then
769		return true
770	end
771	local install_cmd = "pkg install -y " .. shell_escape(package)
772	local test_cmd = "pkg info -q " .. shell_escape(package)
773	if os.getenv("NUAGE_RUN_TESTS") then
774		print(install_cmd)
775		print(test_cmd)
776		return true
777	end
778	if os.execute(test_cmd) then
779		return true
780	end
781	return os.execute(install_cmd)
782end
783
784local function run_pkg_cmd(subcmd)
785	local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd
786	if os.getenv("NUAGE_RUN_TESTS") then
787		print(cmd)
788		return true
789	end
790	return os.execute(cmd)
791end
792local function update_packages()
793	return run_pkg_cmd("update")
794end
795
796local function upgrade_packages()
797	return run_pkg_cmd("upgrade")
798end
799
800local function addfile(file, defer)
801	if type(file) ~= "table" then
802		return false, "Invalid object"
803	end
804	if defer and not file.defer then
805		return true
806	end
807	if not defer and file.defer then
808		return true
809	end
810	if not file.path then
811		return false, "No path provided for the file to write"
812	end
813	local content = nil
814	if file.content then
815		if file.encoding then
816			if file.encoding == "b64" or file.encoding == "base64" then
817				content = decode_base64(file.content)
818			else
819				return false, "Unsupported encoding: " .. file.encoding
820			end
821		else
822			content = file.content
823		end
824	end
825	local mode = "w"
826	if file.append then
827		mode = "a"
828	end
829
830	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
831	if not root then
832		root = ""
833	end
834	local filepath = root .. file.path
835	local f = assert(io.open(filepath, mode))
836	if content then
837		f:write(content)
838	end
839	f:close()
840	if file.permissions then
841		chmod(filepath, file.permissions)
842	end
843	if file.owner then
844		local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
845		if not owner then
846			owner = file.owner
847		end
848		chown(filepath, owner, group)
849	end
850	return true
851end
852
853local function add_fstab_entry(root, device, mount_point, fstype, options, dump_freq, passno)
854	local fstab_path = root .. "/etc/fstab"
855	local f = io.open(fstab_path, "a")
856	if not f then
857		warnmsg("unable to open " .. fstab_path .. " for writing")
858		return false
859	end
860	options = options or "rw"
861	dump_freq = dump_freq or 0
862	passno = passno or 0
863	f:write(string.format("%s\t\t%s\t\t%s\t\t%s\t\t%d\t\t%d\n",
864	    device, mount_point, fstype, options, dump_freq, passno))
865	f:close()
866	return true
867end
868
869local function write_resolv_conf(root, config)
870	local path = root .. "/etc/resolv.conf"
871	local f = io.open(path, "w")
872	if not f then
873		warnmsg("unable to open " .. path .. " for writing")
874		return
875	end
876	if config.domain then
877		f:write("domain " .. config.domain .. "\n")
878	end
879	if config.searchdomains then
880		f:write("search " .. table.concat(config.searchdomains, " ") .. "\n")
881	end
882	if config.sortlist then
883		f:write("sortlist " .. table.concat(config.sortlist, " ") .. "\n")
884	end
885	if config.options then
886		local opts = {}
887		for k, v in pairs(config.options) do
888			table.insert(opts, k .. ":" .. v)
889		end
890		f:write("options " .. table.concat(opts, " ") .. "\n")
891	end
892	if config.nameservers then
893		for _, ns in ipairs(config.nameservers) do
894			f:write("nameserver " .. ns .. "\n")
895		end
896	end
897	f:close()
898end
899
900local function remove_fstab_entry(root, mount_point)
901	local fstab_path = root .. "/etc/fstab"
902	local f = io.open(fstab_path, "r")
903	if not f then
904		return
905	end
906	local lines = {}
907	for line in f:lines() do
908		local fields = {}
909		for field in line:gmatch("%S+") do
910			table.insert(fields, field)
911		end
912		if fields[2] ~= mount_point then
913			table.insert(lines, line)
914		end
915	end
916	f:close()
917	local nf = io.open(fstab_path, "w")
918	if not nf then
919		warnmsg("unable to open " .. fstab_path .. " for writing")
920		return
921	end
922	for _, line in ipairs(lines) do
923		nf:write(line .. "\n")
924	end
925	nf:close()
926end
927
928local function parse_mime_multipart(data)
929	local boundary = data:match("boundary=\"([^\"]+)\"")
930	if not boundary then
931		boundary = data:match("boundary=([^%s;]+)")
932	end
933	if not boundary then
934		return nil
935	end
936	local parts = {}
937	local pos = data:find("\n") or 1
938	local first = data:find("--" .. boundary, pos, true)
939	if not first then
940		return nil
941	end
942	pos = data:find("\n", first)
943	if not pos then return nil end
944	pos = pos + 1
945	while true do
946		local nextb = data:find("--" .. boundary, pos, true)
947		if not nextb then break end
948		local part = data:sub(pos, nextb - 1)
949		part = part:gsub("^\r?\n", ""):gsub("\r?\n$", "")
950		local header_end = part:find("\r?\n\r?\n")
951		local headers_str, body
952		if header_end then
953			headers_str = part:sub(1, header_end - 1)
954			body = part:sub(header_end + 2):gsub("^\r?\n", ""):gsub("\r?\n$", "")
955		else
956			body = part
957		end
958		local ct = "text/plain"
959		if headers_str then
960			local m = headers_str:match("[Cc]ontent%-[Tt]ype:%s*([^%s;]+)")
961			if m then ct = m:lower() end
962		end
963		table.insert(parts, {content_type = ct, body = body})
964		local after = data:sub(nextb + 2 + #boundary, nextb + 3 + #boundary)
965		if after == "--" then break end
966		pos = data:find("\n", nextb) or nextb
967		if pos then pos = pos + 1 end
968	end
969	return parts
970end
971
972local n = {
973	shell_escape = shell_escape,
974	warn = warnmsg,
975	err = errmsg,
976	chmod = chmod,
977	chown = chown,
978	dirname = dirname,
979	mkdir_p = mkdir_p,
980	sethostname = sethostname,
981	settimezone = settimezone,
982	adduser = adduser,
983	addgroup = addgroup,
984	addsshkey = addsshkey,
985	update_sshd_config = update_sshd_config,
986	delete_ssh_host_keys = delete_ssh_host_keys,
987	update_etc_hosts = update_etc_hosts,
988	chpasswd = chpasswd,
989	pkg_bootstrap = pkg_bootstrap,
990	install_package = install_package,
991	update_packages = update_packages,
992	upgrade_packages = upgrade_packages,
993	addsudo = addsudo,
994	adddoas = adddoas,
995	addfile = addfile,
996	decode_base64 = decode_base64,
997	encode_base64 = encode_base64,
998	add_fstab_entry = add_fstab_entry,
999	remove_fstab_entry = remove_fstab_entry,
1000	write_resolv_conf = write_resolv_conf,
1001	parse_mime_multipart = parse_mime_multipart,
1002}
1003
1004return n
1005