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 splitlist(list) 166 local ret = {} 167 if type(list) == "string" then 168 for str in list:gmatch("([^, ]+)") do 169 ret[#ret + 1] = str 170 end 171 elseif type(list) == "table" then 172 ret = list 173 else 174 warnmsg("Invalid type " .. type(list) .. ", expecting table or string") 175 end 176 return ret 177end 178 179local function splitlines(s) 180 local ret = {} 181 182 for line in string.gmatch(s, "[^\n]+") do 183 ret[#ret + 1] = line 184 end 185 186 return ret 187end 188 189local function getgroups() 190 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 191 local cmd = "pw " 192 if root then 193 cmd = cmd .. "-R " .. root .. " " 194 end 195 196 local f = io.popen(cmd .. "groupshow -a 2> /dev/null | cut -d: -f1") 197 local groups = f:read("*a") 198 f:close() 199 200 return splitlines(groups) 201end 202 203local function purge_group(groups) 204 local existing = getgroups() 205 local ret = {} 206 207 for _, group in ipairs(groups) do 208 local found = false 209 for _, eg in ipairs(existing) do 210 if group == eg then 211 found = true 212 break 213 end 214 end 215 if found then 216 ret[#ret + 1] = group 217 else 218 warnmsg("ignoring non-existent group '" .. group .. "'") 219 end 220 end 221 222 return ret 223end 224 225local function adduser(pwd) 226 if (type(pwd) ~= "table") then 227 warnmsg("Argument should be a table") 228 return nil 229 end 230 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 231 local cmd = "pw " 232 if root then 233 cmd = cmd .. "-R " .. root .. " " 234 end 235 local f = io.popen(cmd .. " usershow " .. shell_escape(pwd.name) .. " -7 2> /dev/null") 236 local pwdstr = f:read("*a") 237 f:close() 238 if pwdstr:len() ~= 0 then 239 return pwdstr:match("%a+:.+:%d+:%d+:.*:(.*):.*") 240 end 241 if not pwd.gecos then 242 pwd.gecos = pwd.name .. " User" 243 end 244 if not pwd.homedir then 245 pwd.homedir = "/home/" .. pwd.name 246 end 247 local extraargs = "" 248 if pwd.groups then 249 local list = splitlist(pwd.groups) 250 -- pw complains if the group does not exist, so if the user 251 -- specifies one that cannot be found, nuageinit will generate 252 -- an exception and exit, unlike cloud-init, which only issues 253 -- a warning but creates the user anyway. 254 list = purge_group(list) 255 if #list > 0 then 256 local escaped_list = {} 257 for _, g in ipairs(list) do 258 table.insert(escaped_list, shell_escape(g)) 259 end 260 extraargs = " -G " .. table.concat(escaped_list, ",") 261 end 262 end 263 -- pw will automatically create a group named after the username 264 -- do not add a -g option in this case 265 if pwd.primary_group and pwd.primary_group ~= pwd.name then 266 extraargs = extraargs .. " -g " .. shell_escape(pwd.primary_group) 267 end 268 if not pwd.no_create_home then 269 extraargs = extraargs .. " -m " 270 end 271 if not pwd.shell then 272 pwd.shell = "/bin/sh" 273 end 274 local postcmd = "" 275 local input = nil 276 if pwd.passwd then 277 input = pwd.passwd 278 postcmd = " -H 0" 279 elseif pwd.plain_text_passwd then 280 input = pwd.plain_text_passwd 281 postcmd = " -h 0" 282 end 283 cmd = "pw " 284 if root then 285 cmd = cmd .. "-R " .. root .. " " 286 end 287 cmd = cmd .. "useradd -n " .. shell_escape(pwd.name) .. " -M 0755 -w none " 288 cmd = cmd .. extraargs .. " -c " .. shell_escape(pwd.gecos) 289 cmd = cmd .. " -d " .. shell_escape(pwd.homedir) .. " -s " .. shell_escape(pwd.shell) .. postcmd 290 291 f = io.popen(cmd, "w") 292 if input then 293 f:write(input) 294 end 295 local r = f:close() 296 if not r then 297 warnmsg("fail to add user " .. pwd.name) 298 warnmsg(cmd) 299 return nil 300 end 301 if pwd.locked then 302 cmd = "pw " 303 if root then 304 cmd = cmd .. "-R " .. root .. " " 305 end 306 cmd = cmd .. "lock " .. shell_escape(pwd.name) 307 os.execute(cmd) 308 end 309 return pwd.homedir 310end 311 312local function addgroup(grp) 313 if (type(grp) ~= "table") then 314 warnmsg("Argument should be a table") 315 return false 316 end 317 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 318 local cmd = "pw " 319 if root then 320 cmd = cmd .. "-R " .. root .. " " 321 end 322 local f = io.popen(cmd .. " groupshow " .. shell_escape(grp.name) .. " 2> /dev/null") 323 local grpstr = f:read("*a") 324 f:close() 325 if grpstr:len() ~= 0 then 326 return true 327 end 328 local extraargs = "" 329 if grp.members then 330 local list = splitlist(grp.members) 331 local escaped_list = {} 332 for _, m in ipairs(list) do 333 table.insert(escaped_list, shell_escape(m)) 334 end 335 extraargs = " -M " .. table.concat(escaped_list, ",") 336 end 337 cmd = "pw " 338 if root then 339 cmd = cmd .. "-R " .. root .. " " 340 end 341 cmd = cmd .. "groupadd -n " .. shell_escape(grp.name) .. extraargs 342 local r = os.execute(cmd) 343 if not r then 344 warnmsg("fail to add group " .. grp.name) 345 warnmsg(cmd) 346 return false 347 end 348 return true 349end 350 351local function addsshkey(homedir, key) 352 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 353 if root then 354 homedir = root .. "/" .. homedir 355 end 356 local ak_path = homedir .. "/.ssh/authorized_keys" 357 local dotssh_path = homedir .. "/.ssh" 358 359 -- Check what already exists before creating anything 360 local ak_exists = lfs.attributes(ak_path) ~= nil 361 local dotssh_exists = lfs.attributes(dotssh_path) ~= nil 362 363 -- Ensure .ssh directory exists 364 if not dotssh_exists then 365 local r, err = mkdir_p(dotssh_path) 366 if not r then 367 warnmsg("cannot create " .. dotssh_path .. ": " .. err) 368 return 369 end 370 end 371 372 -- Get homedir attributes for ownership 373 local dirattrs = lfs.attributes(homedir) 374 if not dirattrs then 375 warnmsg("cannot get attributes for " .. homedir) 376 return 377 end 378 379 local f = io.open(ak_path, "a") 380 if not f then 381 warnmsg("impossible to open " .. ak_path) 382 return 383 end 384 f:write(key .. "\n") 385 f:close() 386 387 -- Set permissions and ownership on newly created files/dirs 388 if not ak_exists then 389 chmod(ak_path, "0600") 390 chown(ak_path, dirattrs.uid, dirattrs.gid) 391 end 392 if not dotssh_exists then 393 chmod(dotssh_path, "0700") 394 chown(dotssh_path, dirattrs.uid, dirattrs.gid) 395 end 396end 397 398local function adddoas(pwd) 399 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 400 local localbase = getlocalbase() 401 local etcdir = localbase .. "/etc" 402 if root then 403 etcdir= root .. etcdir 404 end 405 local doasconf = etcdir .. "/doas.conf" 406 407 local doasconf_exists = lfs.attributes(doasconf) ~= nil 408 local etcdir_exists = lfs.attributes(etcdir) ~= nil 409 410 -- Ensure etc directory exists 411 if not etcdir_exists then 412 local r, err = mkdir_p(etcdir) 413 if not r then 414 warnmsg("cannot create " .. etcdir .. ": " .. err) 415 return 416 end 417 end 418 419 local f = io.open(doasconf, "a") 420 if not f then 421 warnmsg("impossible to open " .. doasconf) 422 return 423 end 424 if type(pwd.doas) == "string" then 425 local rule = pwd.doas 426 rule = rule:gsub("%%u", pwd.name) 427 f:write(rule .. "\n") 428 elseif type(pwd.doas) == "table" then 429 for _, str in ipairs(pwd.doas) do 430 local rule = str 431 rule = rule:gsub("%%u", pwd.name) 432 f:write(rule .. "\n") 433 end 434 end 435 f:close() 436 437 -- Set permissions on newly created files/dirs 438 if not doasconf_exists then 439 chmod(doasconf, "0640") 440 end 441 if not etcdir_exists then 442 chmod(etcdir, "0755") 443 end 444end 445 446local function addsudo(pwd) 447 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 448 local localbase = getlocalbase() 449 local sudoers_dir = localbase .. "/etc/sudoers.d" 450 if root then 451 sudoers_dir= root .. sudoers_dir 452 end 453 local sudoers = sudoers_dir .. "/90-nuageinit-users" 454 455 local sudoers_exists = lfs.attributes(sudoers) ~= nil 456 local sudoers_dir_exists = lfs.attributes(sudoers_dir) ~= nil 457 458 -- Ensure sudoers.d directory exists 459 if not sudoers_dir_exists then 460 local r, err = mkdir_p(sudoers_dir) 461 if not r then 462 warnmsg("cannot create " .. sudoers_dir .. ": " .. err) 463 return 464 end 465 end 466 467 local f = io.open(sudoers, "a") 468 if not f then 469 warnmsg("impossible to open " .. sudoers) 470 return 471 end 472 if type(pwd.sudo) == "string" then 473 f:write(pwd.name .. " " .. pwd.sudo .. "\n") 474 elseif type(pwd.sudo) == "table" then 475 for _, str in ipairs(pwd.sudo) do 476 f:write(pwd.name .. " " .. str .. "\n") 477 end 478 end 479 f:close() 480 481 -- Set permissions on newly created files/dirs 482 if not sudoers_exists then 483 chmod(sudoers, "0440") 484 end 485 if not sudoers_dir_exists then 486 chmod(sudoers_dir, "0750") 487 end 488end 489 490local function update_sshd_config(key, value) 491 local sshd_config = "/etc/ssh/sshd_config" 492 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 493 if root then 494 sshd_config = root .. sshd_config 495 end 496 local f = io.open(sshd_config, "r") 497 if not f then 498 -- File does not exist, create it with the given key/value 499 f = io.open(sshd_config, "w") 500 if not f then 501 warnmsg("Unable to open " .. sshd_config .. " for writing") 502 return 503 end 504 f:write(key .. " " .. value .. "\n") 505 f:close() 506 return 507 end 508 -- Read existing content 509 local lines = {} 510 local found = false 511 local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$" 512 for line in f:lines() do 513 local _, _, val = line:lower():find(pattern) 514 if val then 515 found = true 516 if val ~= value then 517 table.insert(lines, key .. " " .. value) 518 else 519 table.insert(lines, line) 520 end 521 else 522 table.insert(lines, line) 523 end 524 end 525 f:close() 526 if not found then 527 table.insert(lines, key .. " " .. value) 528 end 529 -- Write back 530 f = io.open(sshd_config .. ".nuageinit", "w") 531 if not f then 532 warnmsg("Unable to open " .. sshd_config .. ".nuageinit for writing") 533 return 534 end 535 for _, l in ipairs(lines) do 536 f:write(l .. "\n") 537 end 538 f:close() 539 os.rename(sshd_config .. ".nuageinit", sshd_config) 540end 541 542local function delete_ssh_host_keys(root) 543 local ssh_dir = root .. "/etc/ssh" 544 local attrs = lfs.attributes(ssh_dir) 545 if not attrs or attrs.mode ~= "directory" then 546 return 547 end 548 for entry in lfs.dir(ssh_dir) do 549 if entry:match("^ssh_host_.*key") or entry:match("^ssh_host_.*key%.pub") then 550 os.remove(ssh_dir .. "/" .. entry) 551 end 552 end 553end 554 555local function exec_change_password(user, password, type, expire) 556 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 557 local cmd = "pw " 558 if root then 559 cmd = cmd .. "-R " .. root .. " " 560 end 561 local postcmd = " -H 0" 562 local input = password 563 if type ~= nil and type == "text" then 564 postcmd = " -h 0" 565 else 566 if password == "RANDOM" then 567 input = nil 568 postcmd = " -w random" 569 end 570 end 571 cmd = cmd .. "usermod " .. shell_escape(user) .. postcmd 572 if expire then 573 cmd = cmd .. " -p 1" 574 else 575 cmd = cmd .. " -p 0" 576 end 577 local f = io.popen(cmd .. " >/dev/null", "w") 578 if input then 579 f:write(input) 580 end 581 -- ignore stdout to avoid printing the password in case of random password 582 local r = f:close() 583 if not r then 584 warnmsg("fail to change user password ".. user) 585 warnmsg(cmd) 586 end 587end 588 589local function change_password_from_line(line, expire) 590 local user, password = line:match("%s*(%w+):(%S+)%s*") 591 local type = nil 592 if user and password then 593 if password == "R" then 594 password = "RANDOM" 595 end 596 if not password:match("^%$%d+%$%w+%$") then 597 if password ~= "RANDOM" then 598 type = "text" 599 end 600 end 601 exec_change_password(user, password, type, expire) 602 end 603end 604 605local function chpasswd(obj) 606 if type(obj) ~= "table" then 607 warnmsg("Invalid chpasswd entry, expecting an object") 608 return 609 end 610 local expire = false 611 if obj.expire ~= nil then 612 if type(obj.expire) == "boolean" then 613 expire = obj.expire 614 else 615 warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire)) 616 end 617 end 618 if obj.users ~= nil then 619 if type(obj.users) ~= "table" then 620 warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users)) 621 else 622 for _, u in ipairs(obj.users) do 623 if type(u) ~= "table" then 624 warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u)) 625 elseif not u.name then 626 warnmsg("Invalid entry for chpasswd.users: missing 'name'") 627 elseif not u.password then 628 warnmsg("Invalid entry for chpasswd.users: missing 'password'") 629 else 630 exec_change_password(u.name, u.password, u.type, expire) 631 end 632 end 633 end 634 end 635 if obj.list ~= nil then 636 warnmsg("chpasswd.list is deprecated consider using chpasswd.users") 637 if type(obj.list) == "string" then 638 for line in obj.list:gmatch("[^\n]+") do 639 change_password_from_line(line, expire) 640 end 641 elseif type(obj.list) == "table" then 642 for _, u in ipairs(obj.list) do 643 change_password_from_line(u, expire) 644 end 645 end 646 end 647end 648 649local function settimezone(timezone) 650 if timezone == nil then 651 return 652 end 653 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 654 if not root then 655 root = "/" 656 end 657 658 local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone)) 659 660 if not f then 661 warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )") 662 return 663 end 664end 665 666local function pkg_bootstrap() 667 if os.getenv("NUAGE_RUN_TESTS") then 668 return true 669 end 670 if os.execute("pkg -N 2>/dev/null") then 671 return true 672 end 673 print("Bootstrapping pkg") 674 return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap") 675end 676 677local function install_package(package) 678 if package == nil then 679 return true 680 end 681 local install_cmd = "pkg install -y " .. shell_escape(package) 682 local test_cmd = "pkg info -q " .. shell_escape(package) 683 if os.getenv("NUAGE_RUN_TESTS") then 684 print(install_cmd) 685 print(test_cmd) 686 return true 687 end 688 if os.execute(test_cmd) then 689 return true 690 end 691 return os.execute(install_cmd) 692end 693 694local function run_pkg_cmd(subcmd) 695 local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd 696 if os.getenv("NUAGE_RUN_TESTS") then 697 print(cmd) 698 return true 699 end 700 return os.execute(cmd) 701end 702local function update_packages() 703 return run_pkg_cmd("update") 704end 705 706local function upgrade_packages() 707 return run_pkg_cmd("upgrade") 708end 709 710local function addfile(file, defer) 711 if type(file) ~= "table" then 712 return false, "Invalid object" 713 end 714 if defer and not file.defer then 715 return true 716 end 717 if not defer and file.defer then 718 return true 719 end 720 if not file.path then 721 return false, "No path provided for the file to write" 722 end 723 local content = nil 724 if file.content then 725 if file.encoding then 726 if file.encoding == "b64" or file.encoding == "base64" then 727 content = decode_base64(file.content) 728 else 729 return false, "Unsupported encoding: " .. file.encoding 730 end 731 else 732 content = file.content 733 end 734 end 735 local mode = "w" 736 if file.append then 737 mode = "a" 738 end 739 740 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 741 if not root then 742 root = "" 743 end 744 local filepath = root .. file.path 745 local f = assert(io.open(filepath, mode)) 746 if content then 747 f:write(content) 748 end 749 f:close() 750 if file.permissions then 751 chmod(filepath, file.permissions) 752 end 753 if file.owner then 754 local owner, group = string.match(file.owner, "([^:]+):([^:]+)") 755 if not owner then 756 owner = file.owner 757 end 758 chown(filepath, owner, group) 759 end 760 return true 761end 762 763local n = { 764 shell_escape = shell_escape, 765 warn = warnmsg, 766 err = errmsg, 767 chmod = chmod, 768 chown = chown, 769 dirname = dirname, 770 mkdir_p = mkdir_p, 771 sethostname = sethostname, 772 settimezone = settimezone, 773 adduser = adduser, 774 addgroup = addgroup, 775 addsshkey = addsshkey, 776 update_sshd_config = update_sshd_config, 777 delete_ssh_host_keys = delete_ssh_host_keys, 778 chpasswd = chpasswd, 779 pkg_bootstrap = pkg_bootstrap, 780 install_package = install_package, 781 update_packages = update_packages, 782 upgrade_packages = upgrade_packages, 783 addsudo = addsudo, 784 adddoas = adddoas, 785 addfile = addfile 786} 787 788return n 789