xref: /freebsd/libexec/nuageinit/nuage.lua (revision da3890fdccfa7d540ea746182248299b81f95345)
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=" .. shell_escape(hostname) .. "\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	mkdir_p(dirname(filepath))
836	local f = assert(io.open(filepath, mode))
837	if content then
838		f:write(content)
839	end
840	f:close()
841	if file.permissions then
842		chmod(filepath, file.permissions)
843	end
844	if file.owner then
845		local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
846		if not owner then
847			owner = file.owner
848		end
849		chown(filepath, owner, group)
850	end
851	return true
852end
853
854local function add_fstab_entry(root, device, mount_point, fstype, options, dump_freq, passno)
855	local fstab_path = root .. "/etc/fstab"
856	local f = io.open(fstab_path, "a")
857	if not f then
858		warnmsg("unable to open " .. fstab_path .. " for writing")
859		return false
860	end
861	options = options or "rw"
862	dump_freq = dump_freq or 0
863	passno = passno or 0
864	f:write(string.format("%s\t\t%s\t\t%s\t\t%s\t\t%d\t\t%d\n",
865	    device, mount_point, fstype, options, dump_freq, passno))
866	f:close()
867	return true
868end
869
870local function write_resolv_conf(root, config)
871	local path = root .. "/etc/resolv.conf"
872	local f = io.open(path, "w")
873	if not f then
874		warnmsg("unable to open " .. path .. " for writing")
875		return
876	end
877	if config.domain then
878		f:write("domain " .. config.domain .. "\n")
879	end
880	if config.searchdomains then
881		f:write("search " .. table.concat(config.searchdomains, " ") .. "\n")
882	end
883	if config.sortlist then
884		f:write("sortlist " .. table.concat(config.sortlist, " ") .. "\n")
885	end
886	if config.options then
887		local opts = {}
888		for k, v in pairs(config.options) do
889			table.insert(opts, k .. ":" .. v)
890		end
891		f:write("options " .. table.concat(opts, " ") .. "\n")
892	end
893	if config.nameservers then
894		for _, ns in ipairs(config.nameservers) do
895			f:write("nameserver " .. ns .. "\n")
896		end
897	end
898	f:close()
899end
900
901local function remove_fstab_entry(root, mount_point)
902	local fstab_path = root .. "/etc/fstab"
903	local f = io.open(fstab_path, "r")
904	if not f then
905		return
906	end
907	local lines = {}
908	for line in f:lines() do
909		local fields = {}
910		for field in line:gmatch("%S+") do
911			table.insert(fields, field)
912		end
913		if fields[2] ~= mount_point then
914			table.insert(lines, line)
915		end
916	end
917	f:close()
918	local nf = io.open(fstab_path, "w")
919	if not nf then
920		warnmsg("unable to open " .. fstab_path .. " for writing")
921		return
922	end
923	for _, line in ipairs(lines) do
924		nf:write(line .. "\n")
925	end
926	nf:close()
927end
928
929local function parse_mime_multipart(data)
930	local boundary = data:match("boundary=\"([^\"]+)\"")
931	if not boundary then
932		boundary = data:match("boundary=([^%s;]+)")
933	end
934	if not boundary then
935		return nil
936	end
937	local parts = {}
938	local pos = data:find("\n") or 1
939	local first = data:find("--" .. boundary, pos, true)
940	if not first then
941		return nil
942	end
943	pos = data:find("\n", first)
944	if not pos then return nil end
945	pos = pos + 1
946	while true do
947		local nextb = data:find("--" .. boundary, pos, true)
948		if not nextb then break end
949		local part = data:sub(pos, nextb - 1)
950		part = part:gsub("^\r?\n", ""):gsub("\r?\n$", "")
951		local header_end = part:find("\r?\n\r?\n")
952		local headers_str, body
953		if header_end then
954			headers_str = part:sub(1, header_end - 1)
955			body = part:sub(header_end + 2):gsub("^\r?\n", ""):gsub("\r?\n$", "")
956		else
957			body = part
958		end
959		local ct = "text/plain"
960		if headers_str then
961			local m = headers_str:match("[Cc]ontent%-[Tt]ype:%s*([^%s;]+)")
962			if m then ct = m:lower() end
963		end
964		table.insert(parts, {content_type = ct, body = body})
965		local after = data:sub(nextb + 2 + #boundary, nextb + 3 + #boundary)
966		if after == "--" then break end
967		pos = data:find("\n", nextb) or nextb
968		if pos then pos = pos + 1 end
969	end
970	return parts
971end
972
973local n = {
974	shell_escape = shell_escape,
975	warn = warnmsg,
976	err = errmsg,
977	chmod = chmod,
978	chown = chown,
979	dirname = dirname,
980	mkdir_p = mkdir_p,
981	sethostname = sethostname,
982	settimezone = settimezone,
983	adduser = adduser,
984	addgroup = addgroup,
985	addsshkey = addsshkey,
986	update_sshd_config = update_sshd_config,
987	delete_ssh_host_keys = delete_ssh_host_keys,
988	update_etc_hosts = update_etc_hosts,
989	chpasswd = chpasswd,
990	pkg_bootstrap = pkg_bootstrap,
991	install_package = install_package,
992	update_packages = update_packages,
993	upgrade_packages = upgrade_packages,
994	addsudo = addsudo,
995	adddoas = adddoas,
996	addfile = addfile,
997	decode_base64 = decode_base64,
998	encode_base64 = encode_base64,
999	add_fstab_entry = add_fstab_entry,
1000	remove_fstab_entry = remove_fstab_entry,
1001	write_resolv_conf = write_resolv_conf,
1002	parse_mime_multipart = parse_mime_multipart,
1003}
1004
1005return n
1006