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