xref: /freebsd/libexec/nuageinit/nuage.lua (revision 3a1bf59d195ced99c0f69774969d7090d21f6097)
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.lock_passwd or 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, options)
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	if options and options ~= "" then
475		f:write(options .. " " .. key .. "\n")
476	else
477		f:write(key .. "\n")
478	end
479	f:close()
480
481	-- Set permissions and ownership on newly created files/dirs
482	if not ak_exists then
483		chmod(ak_path, "0600")
484		chown(ak_path, dirattrs.uid, dirattrs.gid)
485	end
486	if not dotssh_exists then
487		chmod(dotssh_path, "0700")
488		chown(dotssh_path, dirattrs.uid, dirattrs.gid)
489	end
490end
491
492local function adddoas(pwd)
493	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
494	local localbase = getlocalbase()
495	local etcdir = localbase .. "/etc"
496	if root then
497		etcdir= root .. etcdir
498	end
499	local doasconf = etcdir .. "/doas.conf"
500
501	local doasconf_exists = lfs.attributes(doasconf) ~= nil
502	local etcdir_exists = lfs.attributes(etcdir) ~= nil
503
504	-- Ensure etc directory exists
505	if not etcdir_exists then
506		local r, err = mkdir_p(etcdir)
507		if not r then
508			warnmsg("cannot create " .. etcdir .. ": " .. err)
509			return
510		end
511	end
512
513	local f = io.open(doasconf, "a")
514	if not f then
515		warnmsg("impossible to open " .. doasconf)
516		return
517	end
518	if type(pwd.doas) == "string" then
519		local rule = pwd.doas
520		rule = rule:gsub("%%u", pwd.name)
521		f:write(rule .. "\n")
522	elseif type(pwd.doas) == "table" then
523		for _, str in ipairs(pwd.doas) do
524			local rule = str
525			rule = rule:gsub("%%u", pwd.name)
526			f:write(rule .. "\n")
527		end
528	end
529	f:close()
530
531	-- Set permissions on newly created files/dirs
532	if not doasconf_exists then
533		chmod(doasconf, "0640")
534	end
535	if not etcdir_exists then
536		chmod(etcdir, "0755")
537	end
538end
539
540local function addsudo(pwd)
541	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
542	local localbase = getlocalbase()
543	local sudoers_dir = localbase .. "/etc/sudoers.d"
544	if root then
545		sudoers_dir= root .. sudoers_dir
546	end
547	local sudoers = sudoers_dir .. "/90-nuageinit-users"
548
549	local sudoers_exists = lfs.attributes(sudoers) ~= nil
550	local sudoers_dir_exists = lfs.attributes(sudoers_dir) ~= nil
551
552	-- Ensure sudoers.d directory exists
553	if not sudoers_dir_exists then
554		local r, err = mkdir_p(sudoers_dir)
555		if not r then
556			warnmsg("cannot create " .. sudoers_dir .. ": " .. err)
557			return
558		end
559	end
560
561	local f = io.open(sudoers, "a")
562	if not f then
563		warnmsg("impossible to open " .. sudoers)
564		return
565	end
566	if type(pwd.sudo) == "string" then
567		f:write(pwd.name .. " " .. pwd.sudo .. "\n")
568	elseif type(pwd.sudo) == "table" then
569		for _, str in ipairs(pwd.sudo) do
570			f:write(pwd.name .. " " .. str .. "\n")
571		end
572	end
573	f:close()
574
575	-- Set permissions on newly created files/dirs
576	if not sudoers_exists then
577		chmod(sudoers, "0440")
578	end
579	if not sudoers_dir_exists then
580		chmod(sudoers_dir, "0750")
581	end
582end
583
584local function update_sshd_config(key, value)
585	local sshd_config = "/etc/ssh/sshd_config"
586	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
587	if root then
588		sshd_config = root .. sshd_config
589	end
590	local f = io.open(sshd_config, "r")
591	if not f then
592		-- File does not exist, create it with the given key/value
593		f = io.open(sshd_config, "w")
594		if not f then
595			warnmsg("Unable to open " .. sshd_config .. " for writing")
596			return
597		end
598		f:write(key .. " " .. value .. "\n")
599		f:close()
600		return
601	end
602	-- Read existing content
603	local lines = {}
604	local found = false
605	local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$"
606	for line in f:lines() do
607		local _, _, val = line:lower():find(pattern)
608		if val then
609			found = true
610			if val ~= value then
611				table.insert(lines, key .. " " .. value)
612			else
613				table.insert(lines, line)
614			end
615		else
616			table.insert(lines, line)
617		end
618	end
619	f:close()
620	if not found then
621		table.insert(lines, key .. " " .. value)
622	end
623	-- Write back
624	f = io.open(sshd_config .. ".nuageinit", "w")
625	if not f then
626		warnmsg("Unable to open " .. sshd_config .. ".nuageinit for writing")
627		return
628	end
629	for _, l in ipairs(lines) do
630		f:write(l .. "\n")
631	end
632	f:close()
633	os.rename(sshd_config .. ".nuageinit", sshd_config)
634end
635
636local function delete_ssh_host_keys(root)
637	local ssh_dir = root .. "/etc/ssh"
638	local attrs = lfs.attributes(ssh_dir)
639	if not attrs or attrs.mode ~= "directory" then
640		return
641	end
642	for entry in lfs.dir(ssh_dir) do
643		if entry:match("^ssh_host_.*key") or entry:match("^ssh_host_.*key%.pub") then
644			os.remove(ssh_dir .. "/" .. entry)
645		end
646	end
647end
648
649local function exec_change_password(user, password, type, expire)
650	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
651	local cmd = "pw "
652	if root then
653		cmd = cmd .. "-R " .. root .. " "
654	end
655	local postcmd = " -H 0"
656	local input = password
657	if type ~= nil and type == "text" then
658		postcmd = " -h 0"
659	else
660		if password == "RANDOM" then
661			input = nil
662			postcmd = " -w random"
663		end
664	end
665	cmd = cmd .. "usermod " .. shell_escape(user) .. postcmd
666	if expire then
667		cmd = cmd .. " -p 1"
668	else
669		cmd = cmd .. " -p 0"
670	end
671	local f = io.popen(cmd .. " >/dev/null", "w")
672	if input then
673		f:write(input)
674	end
675	-- ignore stdout to avoid printing the password in case of random password
676	local r = f:close()
677	if not r then
678		warnmsg("fail to change user password ".. user)
679		warnmsg(cmd)
680	end
681end
682
683local function change_password_from_line(line, expire)
684	local user, password = line:match("%s*(%w+):(%S+)%s*")
685	local type = nil
686	if user and password then
687		if password == "R" then
688			password = "RANDOM"
689		end
690		if not password:match("^%$%d+%$%w+%$") then
691			if password ~= "RANDOM" then
692				type = "text"
693			end
694		end
695		exec_change_password(user, password, type, expire)
696	end
697end
698
699local function chpasswd(obj)
700	if type(obj) ~= "table" then
701		warnmsg("Invalid chpasswd entry, expecting an object")
702		return
703	end
704	local expire = false
705	if obj.expire ~= nil then
706		if type(obj.expire) == "boolean" then
707			expire = obj.expire
708		else
709			warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire))
710		end
711	end
712	if obj.users ~= nil then
713		if type(obj.users) ~= "table" then
714			warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users))
715		else
716			for _, u in ipairs(obj.users) do
717				if type(u) ~= "table" then
718					warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u))
719				elseif not u.name then
720					warnmsg("Invalid entry for chpasswd.users: missing 'name'")
721				elseif not u.password then
722					warnmsg("Invalid entry for chpasswd.users: missing 'password'")
723				else
724					exec_change_password(u.name, u.password, u.type, expire)
725				end
726			end
727		end
728	end
729	if obj.list ~= nil then
730		warnmsg("chpasswd.list is deprecated consider using chpasswd.users")
731		if type(obj.list) == "string" then
732			for line in obj.list:gmatch("[^\n]+") do
733				change_password_from_line(line, expire)
734			end
735		elseif type(obj.list) == "table" then
736			for _, u in ipairs(obj.list) do
737				change_password_from_line(u, expire)
738			end
739		end
740	end
741end
742
743local function settimezone(timezone)
744	if timezone == nil then
745		return
746	end
747	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
748	if not root then
749		root = "/"
750	end
751
752	local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone))
753
754	if not f then
755		warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )")
756		return
757	end
758end
759
760local function pkg_bootstrap()
761	if os.getenv("NUAGE_RUN_TESTS") then
762		return true
763	end
764	if os.execute("pkg -N 2>/dev/null") then
765		return true
766	end
767	print("Bootstrapping pkg")
768	return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap")
769end
770
771local function install_package(package)
772	if package == nil then
773		return true
774	end
775	local install_cmd = "pkg install -y " .. shell_escape(package)
776	local test_cmd = "pkg info -q " .. shell_escape(package)
777	if os.getenv("NUAGE_RUN_TESTS") then
778		print(install_cmd)
779		print(test_cmd)
780		return true
781	end
782	if os.execute(test_cmd) then
783		return true
784	end
785	return os.execute(install_cmd)
786end
787
788local function run_pkg_cmd(subcmd)
789	local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd
790	if os.getenv("NUAGE_RUN_TESTS") then
791		print(cmd)
792		return true
793	end
794	return os.execute(cmd)
795end
796local function update_packages()
797	return run_pkg_cmd("update")
798end
799
800local function upgrade_packages()
801	return run_pkg_cmd("upgrade")
802end
803
804local function addfile(file, defer)
805	if type(file) ~= "table" then
806		return false, "Invalid object"
807	end
808	if defer and not file.defer then
809		return true
810	end
811	if not defer and file.defer then
812		return true
813	end
814	if not file.path then
815		return false, "No path provided for the file to write"
816	end
817	local content = nil
818	if file.content then
819		if file.encoding then
820			if file.encoding == "b64" or file.encoding == "base64" then
821				content = decode_base64(file.content)
822			else
823				return false, "Unsupported encoding: " .. file.encoding
824			end
825		else
826			content = file.content
827		end
828	end
829	local mode = "w"
830	if file.append then
831		mode = "a"
832	end
833
834	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
835	if not root then
836		root = ""
837	end
838	local filepath = root .. file.path
839	mkdir_p(dirname(filepath))
840	local f = assert(io.open(filepath, mode))
841	if content then
842		f:write(content)
843	end
844	f:close()
845	if file.permissions then
846		chmod(filepath, file.permissions)
847	end
848	if file.owner then
849		local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
850		if not owner then
851			owner = file.owner
852		end
853		chown(filepath, owner, group)
854	end
855	return true
856end
857
858local function add_fstab_entry(root, device, mount_point, fstype, options, dump_freq, passno)
859	local fstab_path = root .. "/etc/fstab"
860	local f = io.open(fstab_path, "a")
861	if not f then
862		warnmsg("unable to open " .. fstab_path .. " for writing")
863		return false
864	end
865	options = options or "rw"
866	dump_freq = dump_freq or 0
867	passno = passno or 0
868	f:write(string.format("%s\t\t%s\t\t%s\t\t%s\t\t%d\t\t%d\n",
869	    device, mount_point, fstype, options, dump_freq, passno))
870	f:close()
871	return true
872end
873
874local function write_resolv_conf(root, config)
875	local path = root .. "/etc/resolv.conf"
876	local f = io.open(path, "w")
877	if not f then
878		warnmsg("unable to open " .. path .. " for writing")
879		return
880	end
881	if config.domain then
882		f:write("domain " .. config.domain .. "\n")
883	end
884	if config.searchdomains then
885		f:write("search " .. table.concat(config.searchdomains, " ") .. "\n")
886	end
887	if config.sortlist then
888		f:write("sortlist " .. table.concat(config.sortlist, " ") .. "\n")
889	end
890	if config.options then
891		local opts = {}
892		for k, v in pairs(config.options) do
893			table.insert(opts, k .. ":" .. v)
894		end
895		f:write("options " .. table.concat(opts, " ") .. "\n")
896	end
897	if config.nameservers then
898		for _, ns in ipairs(config.nameservers) do
899			f:write("nameserver " .. ns .. "\n")
900		end
901	end
902	f:close()
903end
904
905local function remove_fstab_entry(root, mount_point)
906	local fstab_path = root .. "/etc/fstab"
907	local f = io.open(fstab_path, "r")
908	if not f then
909		return
910	end
911	local lines = {}
912	for line in f:lines() do
913		local fields = {}
914		for field in line:gmatch("%S+") do
915			table.insert(fields, field)
916		end
917		if fields[2] ~= mount_point then
918			table.insert(lines, line)
919		end
920	end
921	f:close()
922	local nf = io.open(fstab_path, "w")
923	if not nf then
924		warnmsg("unable to open " .. fstab_path .. " for writing")
925		return
926	end
927	for _, line in ipairs(lines) do
928		nf:write(line .. "\n")
929	end
930	nf:close()
931end
932
933local function parse_mime_multipart(data)
934	local boundary = data:match("boundary=\"([^\"]+)\"")
935	if not boundary then
936		boundary = data:match("boundary=([^%s;]+)")
937	end
938	if not boundary then
939		return nil
940	end
941	local parts = {}
942	local pos = data:find("\n") or 1
943	local first = data:find("--" .. boundary, pos, true)
944	if not first then
945		return nil
946	end
947	pos = data:find("\n", first)
948	if not pos then return nil end
949	pos = pos + 1
950	while true do
951		local nextb = data:find("--" .. boundary, pos, true)
952		if not nextb then break end
953		local part = data:sub(pos, nextb - 1)
954		part = part:gsub("^\r?\n", ""):gsub("\r?\n$", "")
955		local header_end = part:find("\r?\n\r?\n")
956		local headers_str, body
957		if header_end then
958			headers_str = part:sub(1, header_end - 1)
959			body = part:sub(header_end + 2):gsub("^\r?\n", ""):gsub("\r?\n$", "")
960		else
961			body = part
962		end
963		local ct = "text/plain"
964		if headers_str then
965			local m = headers_str:match("[Cc]ontent%-[Tt]ype:%s*([^%s;]+)")
966			if m then ct = m:lower() end
967		end
968		table.insert(parts, {content_type = ct, body = body})
969		local after = data:sub(nextb + 2 + #boundary, nextb + 3 + #boundary)
970		if after == "--" then break end
971		pos = data:find("\n", nextb) or nextb
972		if pos then pos = pos + 1 end
973	end
974	return parts
975end
976
977local n = {
978	shell_escape = shell_escape,
979	warn = warnmsg,
980	err = errmsg,
981	chmod = chmod,
982	chown = chown,
983	dirname = dirname,
984	mkdir_p = mkdir_p,
985	sethostname = sethostname,
986	settimezone = settimezone,
987	adduser = adduser,
988	addgroup = addgroup,
989	addsshkey = addsshkey,
990	update_sshd_config = update_sshd_config,
991	delete_ssh_host_keys = delete_ssh_host_keys,
992	update_etc_hosts = update_etc_hosts,
993	chpasswd = chpasswd,
994	pkg_bootstrap = pkg_bootstrap,
995	install_package = install_package,
996	update_packages = update_packages,
997	upgrade_packages = upgrade_packages,
998	addsudo = addsudo,
999	adddoas = adddoas,
1000	addfile = addfile,
1001	decode_base64 = decode_base64,
1002	encode_base64 = encode_base64,
1003	add_fstab_entry = add_fstab_entry,
1004	remove_fstab_entry = remove_fstab_entry,
1005	write_resolv_conf = write_resolv_conf,
1006	parse_mime_multipart = parse_mime_multipart,
1007}
1008
1009return n
1010