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