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