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 else 609 for _, u in ipairs(obj.users) do 610 if type(u) ~= "table" then 611 warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u)) 612 elseif not u.name then 613 warnmsg("Invalid entry for chpasswd.users: missing 'name'") 614 elseif not u.password then 615 warnmsg("Invalid entry for chpasswd.users: missing 'password'") 616 else 617 exec_change_password(u.name, u.password, u.type, expire) 618 end 619 end 620 end 621 end 622 if obj.list ~= nil then 623 warnmsg("chpasswd.list is deprecated consider using chpasswd.users") 624 if type(obj.list) == "string" then 625 for line in obj.list:gmatch("[^\n]+") do 626 change_password_from_line(line, expire) 627 end 628 elseif type(obj.list) == "table" then 629 for _, u in ipairs(obj.list) do 630 change_password_from_line(u, expire) 631 end 632 end 633 end 634end 635 636local function settimezone(timezone) 637 if timezone == nil then 638 return 639 end 640 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 641 if not root then 642 root = "/" 643 end 644 645 local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone)) 646 647 if not f then 648 warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )") 649 return 650 end 651end 652 653local function pkg_bootstrap() 654 if os.getenv("NUAGE_RUN_TESTS") then 655 return true 656 end 657 if os.execute("pkg -N 2>/dev/null") then 658 return true 659 end 660 print("Bootstrapping pkg") 661 return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap") 662end 663 664local function install_package(package) 665 if package == nil then 666 return true 667 end 668 local install_cmd = "pkg install -y " .. shell_escape(package) 669 local test_cmd = "pkg info -q " .. shell_escape(package) 670 if os.getenv("NUAGE_RUN_TESTS") then 671 print(install_cmd) 672 print(test_cmd) 673 return true 674 end 675 if os.execute(test_cmd) then 676 return true 677 end 678 return os.execute(install_cmd) 679end 680 681local function run_pkg_cmd(subcmd) 682 local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd 683 if os.getenv("NUAGE_RUN_TESTS") then 684 print(cmd) 685 return true 686 end 687 return os.execute(cmd) 688end 689local function update_packages() 690 return run_pkg_cmd("update") 691end 692 693local function upgrade_packages() 694 return run_pkg_cmd("upgrade") 695end 696 697local function addfile(file, defer) 698 if type(file) ~= "table" then 699 return false, "Invalid object" 700 end 701 if defer and not file.defer then 702 return true 703 end 704 if not defer and file.defer then 705 return true 706 end 707 if not file.path then 708 return false, "No path provided for the file to write" 709 end 710 local content = nil 711 if file.content then 712 if file.encoding then 713 if file.encoding == "b64" or file.encoding == "base64" then 714 content = decode_base64(file.content) 715 else 716 return false, "Unsupported encoding: " .. file.encoding 717 end 718 else 719 content = file.content 720 end 721 end 722 local mode = "w" 723 if file.append then 724 mode = "a" 725 end 726 727 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 728 if not root then 729 root = "" 730 end 731 local filepath = root .. file.path 732 local f = assert(io.open(filepath, mode)) 733 if content then 734 f:write(content) 735 end 736 f:close() 737 if file.permissions then 738 chmod(filepath, file.permissions) 739 end 740 if file.owner then 741 local owner, group = string.match(file.owner, "([^:]+):([^:]+)") 742 if not owner then 743 owner = file.owner 744 end 745 chown(filepath, owner, group) 746 end 747 return true 748end 749 750local n = { 751 shell_escape = shell_escape, 752 warn = warnmsg, 753 err = errmsg, 754 chmod = chmod, 755 chown = chown, 756 dirname = dirname, 757 mkdir_p = mkdir_p, 758 sethostname = sethostname, 759 settimezone = settimezone, 760 adduser = adduser, 761 addgroup = addgroup, 762 addsshkey = addsshkey, 763 update_sshd_config = update_sshd_config, 764 chpasswd = chpasswd, 765 pkg_bootstrap = pkg_bootstrap, 766 install_package = install_package, 767 update_packages = update_packages, 768 upgrade_packages = upgrade_packages, 769 addsudo = addsudo, 770 adddoas = adddoas, 771 addfile = addfile 772} 773 774return n 775