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 exec_change_password(user, password, type, expire) 543 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 544 local cmd = "pw " 545 if root then 546 cmd = cmd .. "-R " .. root .. " " 547 end 548 local postcmd = " -H 0" 549 local input = password 550 if type ~= nil and type == "text" then 551 postcmd = " -h 0" 552 else 553 if password == "RANDOM" then 554 input = nil 555 postcmd = " -w random" 556 end 557 end 558 cmd = cmd .. "usermod " .. shell_escape(user) .. postcmd 559 if expire then 560 cmd = cmd .. " -p 1" 561 else 562 cmd = cmd .. " -p 0" 563 end 564 local f = io.popen(cmd .. " >/dev/null", "w") 565 if input then 566 f:write(input) 567 end 568 -- ignore stdout to avoid printing the password in case of random password 569 local r = f:close() 570 if not r then 571 warnmsg("fail to change user password ".. user) 572 warnmsg(cmd) 573 end 574end 575 576local function change_password_from_line(line, expire) 577 local user, password = line:match("%s*(%w+):(%S+)%s*") 578 local type = nil 579 if user and password then 580 if password == "R" then 581 password = "RANDOM" 582 end 583 if not password:match("^%$%d+%$%w+%$") then 584 if password ~= "RANDOM" then 585 type = "text" 586 end 587 end 588 exec_change_password(user, password, type, expire) 589 end 590end 591 592local function chpasswd(obj) 593 if type(obj) ~= "table" then 594 warnmsg("Invalid chpasswd entry, expecting an object") 595 return 596 end 597 local expire = false 598 if obj.expire ~= nil then 599 if type(obj.expire) == "boolean" then 600 expire = obj.expire 601 else 602 warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire)) 603 end 604 end 605 if obj.users ~= nil then 606 if type(obj.users) ~= "table" then 607 warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users)) 608 goto list 609 end 610 for _, u in ipairs(obj.users) do 611 if type(u) ~= "table" then 612 warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u)) 613 goto next 614 end 615 if not u.name then 616 warnmsg("Invalid entry for chpasswd.users: missing 'name'") 617 goto next 618 end 619 if not u.password then 620 warnmsg("Invalid entry for chpasswd.users: missing 'password'") 621 goto next 622 end 623 exec_change_password(u.name, u.password, u.type, expire) 624 ::next:: 625 end 626 end 627 ::list:: 628 if obj.list ~= nil then 629 warnmsg("chpasswd.list is deprecated consider using chpasswd.users") 630 if type(obj.list) == "string" then 631 for line in obj.list:gmatch("[^\n]+") do 632 change_password_from_line(line, expire) 633 end 634 elseif type(obj.list) == "table" then 635 for _, u in ipairs(obj.list) do 636 change_password_from_line(u, expire) 637 end 638 end 639 end 640end 641 642local function settimezone(timezone) 643 if timezone == nil then 644 return 645 end 646 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 647 if not root then 648 root = "/" 649 end 650 651 local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone)) 652 653 if not f then 654 warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )") 655 return 656 end 657end 658 659local function pkg_bootstrap() 660 if os.getenv("NUAGE_RUN_TESTS") then 661 return true 662 end 663 if os.execute("pkg -N 2>/dev/null") then 664 return true 665 end 666 print("Bootstrapping pkg") 667 return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap") 668end 669 670local function install_package(package) 671 if package == nil then 672 return true 673 end 674 local install_cmd = "pkg install -y " .. shell_escape(package) 675 local test_cmd = "pkg info -q " .. shell_escape(package) 676 if os.getenv("NUAGE_RUN_TESTS") then 677 print(install_cmd) 678 print(test_cmd) 679 return true 680 end 681 if os.execute(test_cmd) then 682 return true 683 end 684 return os.execute(install_cmd) 685end 686 687local function run_pkg_cmd(subcmd) 688 local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd 689 if os.getenv("NUAGE_RUN_TESTS") then 690 print(cmd) 691 return true 692 end 693 return os.execute(cmd) 694end 695local function update_packages() 696 return run_pkg_cmd("update") 697end 698 699local function upgrade_packages() 700 return run_pkg_cmd("upgrade") 701end 702 703local function addfile(file, defer) 704 if type(file) ~= "table" then 705 return false, "Invalid object" 706 end 707 if defer and not file.defer then 708 return true 709 end 710 if not defer and file.defer then 711 return true 712 end 713 if not file.path then 714 return false, "No path provided for the file to write" 715 end 716 local content = nil 717 if file.content then 718 if file.encoding then 719 if file.encoding == "b64" or file.encoding == "base64" then 720 content = decode_base64(file.content) 721 else 722 return false, "Unsupported encoding: " .. file.encoding 723 end 724 else 725 content = file.content 726 end 727 end 728 local mode = "w" 729 if file.append then 730 mode = "a" 731 end 732 733 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 734 if not root then 735 root = "" 736 end 737 local filepath = root .. file.path 738 local f = assert(io.open(filepath, mode)) 739 if content then 740 f:write(content) 741 end 742 f:close() 743 if file.permissions then 744 chmod(filepath, file.permissions) 745 end 746 if file.owner then 747 local owner, group = string.match(file.owner, "([^:]+):([^:]+)") 748 if not owner then 749 owner = file.owner 750 end 751 chown(filepath, owner, group) 752 end 753 return true 754end 755 756local n = { 757 shell_escape = shell_escape, 758 warn = warnmsg, 759 err = errmsg, 760 chmod = chmod, 761 chown = chown, 762 dirname = dirname, 763 mkdir_p = mkdir_p, 764 sethostname = sethostname, 765 settimezone = settimezone, 766 adduser = adduser, 767 addgroup = addgroup, 768 addsshkey = addsshkey, 769 update_sshd_config = update_sshd_config, 770 chpasswd = chpasswd, 771 pkg_bootstrap = pkg_bootstrap, 772 install_package = install_package, 773 update_packages = update_packages, 774 upgrade_packages = upgrade_packages, 775 addsudo = addsudo, 776 adddoas = adddoas, 777 addfile = addfile 778} 779 780return n 781