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 local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 24 input = string.gsub(input, '[^'..b..'=]', '') 25 26 local result = {} 27 local bits = '' 28 29 -- convert all characters in bits 30 for i = 1, #input do 31 local x = input:sub(i, i) 32 if x == '=' then 33 break 34 end 35 local f = b:find(x) - 1 36 for j = 6, 1, -1 do 37 bits = bits .. (f % 2^j - f % 2^(j-1) > 0 and '1' or '0') 38 end 39 end 40 41 for i = 1, #bits, 8 do 42 local byte = bits:sub(i, i + 7) 43 if #byte == 8 then 44 local c = 0 45 for j = 1, 8 do 46 c = c + (byte:sub(j, j) == '1' and 2^(8 - j) or 0) 47 end 48 table.insert(result, string.char(c)) 49 end 50 end 51 52 return table.concat(result) 53end 54 55local function shell_escape(s) 56 return "'" .. string.gsub(s, "'", "'\\''") .. "'" 57end 58 59local function warnmsg(str, prepend) 60 if not str then 61 return 62 end 63 local tag = "" 64 if prepend ~= false then 65 tag = "nuageinit: " 66 end 67 io.stderr:write(tag .. str .. "\n") 68end 69 70local function errmsg(str, prepend) 71 warnmsg(str, prepend) 72 os.exit(1) 73end 74 75local function chmod(path, mode) 76 mode = tonumber(mode, 8) 77 local _, err, msg = sys_stat.chmod(path, mode) 78 if err then 79 errmsg("chmod(" .. path .. ", " .. mode .. ") failed: " .. msg) 80 end 81end 82 83local function chown(path, owner, group) 84 local _, err, msg = unistd.chown(path, owner, group) 85 if err then 86 errmsg("chown(" .. path .. ", " .. owner .. ", " .. group .. ") failed: " .. msg) 87 end 88end 89 90local function dirname(oldpath) 91 if not oldpath then 92 return nil 93 end 94 local path = oldpath:gsub("[^/]+/*$", "") 95 if path == "" then 96 return nil 97 end 98 return path 99end 100 101local function mkdir_p(path) 102 if lfs.attributes(path, "mode") ~= nil then 103 return true 104 end 105 local r, err = mkdir_p(dirname(path)) 106 if not r then 107 return nil, err .. " (creating " .. path .. ")" 108 end 109 return lfs.mkdir(path) 110end 111 112local function sethostname(hostname) 113 if hostname == nil then 114 return 115 end 116 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 117 if not root then 118 root = "" 119 end 120 local hostnamepath = root .. "/etc/rc.conf.d/hostname" 121 122 mkdir_p(dirname(hostnamepath)) 123 local f, err = io.open(hostnamepath, "w") 124 if not f then 125 warnmsg("Impossible to open " .. hostnamepath .. ":" .. err) 126 return 127 end 128 f:write('hostname="' .. hostname:gsub('"', '\\"') .. '"\n') 129 f:close() 130end 131 132local function splitlist(list) 133 local ret = {} 134 if type(list) == "string" then 135 for str in list:gmatch("([^, ]+)") do 136 ret[#ret + 1] = str 137 end 138 elseif type(list) == "table" then 139 ret = list 140 else 141 warnmsg("Invalid type " .. type(list) .. ", expecting table or string") 142 end 143 return ret 144end 145 146local function splitlines(s) 147 local ret = {} 148 149 for line in string.gmatch(s, "[^\n]+") do 150 ret[#ret + 1] = line 151 end 152 153 return ret 154end 155 156local function getgroups() 157 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 158 local cmd = "pw " 159 if root then 160 cmd = cmd .. "-R " .. root .. " " 161 end 162 163 local f = io.popen(cmd .. "groupshow -a 2> /dev/null | cut -d: -f1") 164 local groups = f:read("*a") 165 f:close() 166 167 return splitlines(groups) 168end 169 170local function checkgroup(group) 171 local groups = getgroups() 172 173 for _, group2chk in ipairs(groups) do 174 if group == group2chk then 175 return true 176 end 177 end 178 179 return false 180end 181 182local function purge_group(groups) 183 local ret = {} 184 185 for _, group in ipairs(groups) do 186 if checkgroup(group) then 187 ret[#ret + 1] = group 188 else 189 warnmsg("ignoring non-existent group '" .. group .. "'") 190 end 191 end 192 193 return ret 194end 195 196local function adduser(pwd) 197 if (type(pwd) ~= "table") then 198 warnmsg("Argument should be a table") 199 return nil 200 end 201 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 202 local cmd = "pw " 203 if root then 204 cmd = cmd .. "-R " .. root .. " " 205 end 206 local f = io.popen(cmd .. " usershow " .. shell_escape(pwd.name) .. " -7 2> /dev/null") 207 local pwdstr = f:read("*a") 208 f:close() 209 if pwdstr:len() ~= 0 then 210 return pwdstr:match("%a+:.+:%d+:%d+:.*:(.*):.*") 211 end 212 if not pwd.gecos then 213 pwd.gecos = pwd.name .. " User" 214 end 215 if not pwd.homedir then 216 pwd.homedir = "/home/" .. pwd.name 217 end 218 local extraargs = "" 219 if pwd.groups then 220 local list = splitlist(pwd.groups) 221 -- pw complains if the group does not exist, so if the user 222 -- specifies one that cannot be found, nuageinit will generate 223 -- an exception and exit, unlike cloud-init, which only issues 224 -- a warning but creates the user anyway. 225 list = purge_group(list) 226 if #list > 0 then 227 local escaped_list = {} 228 for _, g in ipairs(list) do 229 table.insert(escaped_list, shell_escape(g)) 230 end 231 extraargs = " -G " .. table.concat(escaped_list, ",") 232 end 233 end 234 -- pw will automatically create a group named after the username 235 -- do not add a -g option in this case 236 if pwd.primary_group and pwd.primary_group ~= pwd.name then 237 extraargs = extraargs .. " -g " .. shell_escape(pwd.primary_group) 238 end 239 if not pwd.no_create_home then 240 extraargs = extraargs .. " -m " 241 end 242 if not pwd.shell then 243 pwd.shell = "/bin/sh" 244 end 245 local precmd = "" 246 local postcmd = "" 247 local input = nil 248 if pwd.passwd then 249 input = pwd.passwd 250 postcmd = " -H 0" 251 elseif pwd.plain_text_passwd then 252 input = pwd.plain_text_passwd 253 postcmd = " -h 0" 254 end 255 cmd = precmd .. "pw " 256 if root then 257 cmd = cmd .. "-R " .. root .. " " 258 end 259 cmd = cmd .. "useradd -n " .. shell_escape(pwd.name) .. " -M 0755 -w none " 260 cmd = cmd .. extraargs .. " -c " .. shell_escape(pwd.gecos) 261 cmd = cmd .. " -d " .. shell_escape(pwd.homedir) .. " -s " .. shell_escape(pwd.shell) .. postcmd 262 263 f = io.popen(cmd, "w") 264 if input then 265 f:write(input) 266 end 267 local r = f:close(cmd) 268 if not r then 269 warnmsg("fail to add user " .. pwd.name) 270 warnmsg(cmd) 271 return nil 272 end 273 if pwd.locked then 274 cmd = "pw " 275 if root then 276 cmd = cmd .. "-R " .. root .. " " 277 end 278 cmd = cmd .. "lock " .. shell_escape(pwd.name) 279 os.execute(cmd) 280 end 281 return pwd.homedir 282end 283 284local function addgroup(grp) 285 if (type(grp) ~= "table") then 286 warnmsg("Argument should be a table") 287 return false 288 end 289 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 290 local cmd = "pw " 291 if root then 292 cmd = cmd .. "-R " .. root .. " " 293 end 294 local f = io.popen(cmd .. " groupshow " .. shell_escape(grp.name) .. " 2> /dev/null") 295 local grpstr = f:read("*a") 296 f:close() 297 if grpstr:len() ~= 0 then 298 return true 299 end 300 local extraargs = "" 301 if grp.members then 302 local list = splitlist(grp.members) 303 local escaped_list = {} 304 for _, m in ipairs(list) do 305 table.insert(escaped_list, shell_escape(m)) 306 end 307 extraargs = " -M " .. table.concat(escaped_list, ",") 308 end 309 cmd = "pw " 310 if root then 311 cmd = cmd .. "-R " .. root .. " " 312 end 313 cmd = cmd .. "groupadd -n " .. shell_escape(grp.name) .. extraargs 314 local r = os.execute(cmd) 315 if not r then 316 warnmsg("fail to add group " .. grp.name) 317 warnmsg(cmd) 318 return false 319 end 320 return true 321end 322 323local function addsshkey(homedir, key) 324 local chownak = false 325 local chowndotssh = false 326 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 327 if root then 328 homedir = root .. "/" .. homedir 329 end 330 local ak_path = homedir .. "/.ssh/authorized_keys" 331 local dotssh_path = homedir .. "/.ssh" 332 local dirattrs = lfs.attributes(ak_path) 333 if dirattrs == nil then 334 chownak = true 335 dirattrs = lfs.attributes(dotssh_path) 336 if dirattrs == nil then 337 assert(lfs.mkdir(dotssh_path)) 338 chowndotssh = true 339 dirattrs = lfs.attributes(homedir) 340 end 341 end 342 343 local f = io.open(ak_path, "a") 344 if not f then 345 warnmsg("impossible to open " .. ak_path) 346 return 347 end 348 f:write(key .. "\n") 349 f:close() 350 if chownak then 351 chmod(ak_path, "0600") 352 chown(ak_path, dirattrs.uid, dirattrs.gid) 353 end 354 if chowndotssh then 355 chmod(dotssh_path, "0700") 356 chown(dotssh_path, dirattrs.uid, dirattrs.gid) 357 end 358end 359 360local function adddoas(pwd) 361 local chmodetcdir = false 362 local chmoddoasconf = false 363 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 364 local localbase = getlocalbase() 365 local etcdir = localbase .. "/etc" 366 if root then 367 etcdir= root .. etcdir 368 end 369 local doasconf = etcdir .. "/doas.conf" 370 local doasconf_attr = lfs.attributes(doasconf) 371 if doasconf_attr == nil then 372 chmoddoasconf = true 373 local dirattrs = lfs.attributes(etcdir) 374 if dirattrs == nil then 375 local r, err = mkdir_p(etcdir) 376 if not r then 377 return nil, err .. " (creating " .. etcdir .. ")" 378 end 379 chmodetcdir = true 380 end 381 end 382 local f = io.open(doasconf, "a") 383 if not f then 384 warnmsg("impossible to open " .. doasconf) 385 return 386 end 387 if type(pwd.doas) == "string" then 388 local rule = pwd.doas 389 rule = rule:gsub("%%u", pwd.name) 390 f:write(rule .. "\n") 391 elseif type(pwd.doas) == "table" then 392 for _, str in ipairs(pwd.doas) do 393 local rule = str 394 rule = rule:gsub("%%u", pwd.name) 395 f:write(rule .. "\n") 396 end 397 end 398 f:close() 399 if chmoddoasconf then 400 chmod(doasconf, "0640") 401 end 402 if chmodetcdir then 403 chmod(etcdir, "0755") 404 end 405end 406 407local function addsudo(pwd) 408 local chmodsudoersd = false 409 local chmodsudoers = false 410 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 411 local localbase = getlocalbase() 412 local sudoers_dir = localbase .. "/etc/sudoers.d" 413 if root then 414 sudoers_dir= root .. sudoers_dir 415 end 416 local sudoers = sudoers_dir .. "/90-nuageinit-users" 417 local sudoers_attr = lfs.attributes(sudoers) 418 if sudoers_attr == nil then 419 chmodsudoers = true 420 local dirattrs = lfs.attributes(sudoers_dir) 421 if dirattrs == nil then 422 local r, err = mkdir_p(sudoers_dir) 423 if not r then 424 return nil, err .. " (creating " .. sudoers_dir .. ")" 425 end 426 chmodsudoersd = true 427 end 428 end 429 local f = io.open(sudoers, "a") 430 if not f then 431 warnmsg("impossible to open " .. sudoers) 432 return 433 end 434 if type(pwd.sudo) == "string" then 435 f:write(pwd.name .. " " .. pwd.sudo .. "\n") 436 elseif type(pwd.sudo) == "table" then 437 for _, str in ipairs(pwd.sudo) do 438 f:write(pwd.name .. " " .. str .. "\n") 439 end 440 end 441 f:close() 442 if chmodsudoers then 443 chmod(sudoers, "0440") 444 end 445 if chmodsudoersd then 446 chmod(sudoers_dir, "0750") 447 end 448end 449 450local function update_sshd_config(key, value) 451 local sshd_config = "/etc/ssh/sshd_config" 452 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 453 if root then 454 sshd_config = root .. sshd_config 455 end 456 local f = assert(io.open(sshd_config, "r+")) 457 local tgt = assert(io.open(sshd_config .. ".nuageinit", "w")) 458 local found = false 459 local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$" 460 while true do 461 local line = f:read() 462 if line == nil then break end 463 local _, _, val = line:lower():find(pattern) 464 if val then 465 found = true 466 if val == value then 467 assert(tgt:write(line .. "\n")) 468 else 469 assert(tgt:write(key .. " " .. value .. "\n")) 470 end 471 else 472 assert(tgt:write(line .. "\n")) 473 end 474 end 475 if not found then 476 assert(tgt:write(key .. " " .. value .. "\n")) 477 end 478 assert(f:close()) 479 assert(tgt:close()) 480 os.rename(sshd_config .. ".nuageinit", sshd_config) 481end 482 483local function exec_change_password(user, password, type, expire) 484 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 485 local cmd = "pw " 486 if root then 487 cmd = cmd .. "-R " .. root .. " " 488 end 489 local postcmd = " -H 0" 490 local input = password 491 if type ~= nil and type == "text" then 492 postcmd = " -h 0" 493 else 494 if password == "RANDOM" then 495 input = nil 496 postcmd = " -w random" 497 end 498 end 499 cmd = cmd .. "usermod " .. shell_escape(user) .. postcmd 500 if expire then 501 cmd = cmd .. " -p 1" 502 else 503 cmd = cmd .. " -p 0" 504 end 505 local f = io.popen(cmd .. " >/dev/null", "w") 506 if input then 507 f:write(input) 508 end 509 -- ignore stdout to avoid printing the password in case of random password 510 local r = f:close(cmd) 511 if not r then 512 warnmsg("fail to change user password ".. user) 513 warnmsg(cmd) 514 end 515end 516 517local function change_password_from_line(line, expire) 518 local user, password = line:match("%s*(%w+):(%S+)%s*") 519 local type = nil 520 if user and password then 521 if password == "R" then 522 password = "RANDOM" 523 end 524 if not password:match("^%$%d+%$%w+%$") then 525 if password ~= "RANDOM" then 526 type = "text" 527 end 528 end 529 exec_change_password(user, password, type, expire) 530 end 531end 532 533local function chpasswd(obj) 534 if type(obj) ~= "table" then 535 warnmsg("Invalid chpasswd entry, expecting an object") 536 return 537 end 538 local expire = false 539 if obj.expire ~= nil then 540 if type(obj.expire) == "boolean" then 541 expire = obj.expire 542 else 543 warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire)) 544 end 545 end 546 if obj.users ~= nil then 547 if type(obj.users) ~= "table" then 548 warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users)) 549 goto list 550 end 551 for _, u in ipairs(obj.users) do 552 if type(u) ~= "table" then 553 warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u)) 554 goto next 555 end 556 if not u.name then 557 warnmsg("Invalid entry for chpasswd.users: missing 'name'") 558 goto next 559 end 560 if not u.password then 561 warnmsg("Invalid entry for chpasswd.users: missing 'password'") 562 goto next 563 end 564 exec_change_password(u.name, u.password, u.type, expire) 565 ::next:: 566 end 567 end 568 ::list:: 569 if obj.list ~= nil then 570 warnmsg("chpasswd.list is deprecated consider using chpasswd.users") 571 if type(obj.list) == "string" then 572 for line in obj.list:gmatch("[^\n]+") do 573 change_password_from_line(line, expire) 574 end 575 elseif type(obj.list) == "table" then 576 for _, u in ipairs(obj.list) do 577 change_password_from_line(u, expire) 578 end 579 end 580 end 581end 582 583local function settimezone(timezone) 584 if timezone == nil then 585 return 586 end 587 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 588 if not root then 589 root = "/" 590 end 591 592 local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone)) 593 594 if not f then 595 warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )") 596 return 597 end 598end 599 600local function pkg_bootstrap() 601 if os.getenv("NUAGE_RUN_TESTS") then 602 return true 603 end 604 if os.execute("pkg -N 2>/dev/null") then 605 return true 606 end 607 print("Bootstrapping pkg") 608 return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap") 609end 610 611local function install_package(package) 612 if package == nil then 613 return true 614 end 615 local install_cmd = "pkg install -y " .. shell_escape(package) 616 local test_cmd = "pkg info -q " .. shell_escape(package) 617 if os.getenv("NUAGE_RUN_TESTS") then 618 print(install_cmd) 619 print(test_cmd) 620 return true 621 end 622 if os.execute(test_cmd) then 623 return true 624 end 625 return os.execute(install_cmd) 626end 627 628local function run_pkg_cmd(subcmd) 629 local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd 630 if os.getenv("NUAGE_RUN_TESTS") then 631 print(cmd) 632 return true 633 end 634 return os.execute(cmd) 635end 636local function update_packages() 637 return run_pkg_cmd("update") 638end 639 640local function upgrade_packages() 641 return run_pkg_cmd("upgrade") 642end 643 644local function addfile(file, defer) 645 if type(file) ~= "table" then 646 return false, "Invalid object" 647 end 648 if defer and not file.defer then 649 return true 650 end 651 if not defer and file.defer then 652 return true 653 end 654 if not file.path then 655 return false, "No path provided for the file to write" 656 end 657 local content = nil 658 if file.content then 659 if file.encoding then 660 if file.encoding == "b64" or file.encoding == "base64" then 661 content = decode_base64(file.content) 662 else 663 return false, "Unsupported encoding: " .. file.encoding 664 end 665 else 666 content = file.content 667 end 668 end 669 local mode = "w" 670 if file.append then 671 mode = "a" 672 end 673 674 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 675 if not root then 676 root = "" 677 end 678 local filepath = root .. file.path 679 local f = assert(io.open(filepath, mode)) 680 if content then 681 f:write(content) 682 end 683 f:close() 684 if file.permissions then 685 chmod(filepath, file.permissions) 686 end 687 if file.owner then 688 local owner, group = string.match(file.owner, "([^:]+):([^:]+)") 689 if not owner then 690 owner = file.owner 691 end 692 chown(filepath, owner, group) 693 end 694 return true 695end 696 697local n = { 698 shell_escape = shell_escape, 699 warn = warnmsg, 700 err = errmsg, 701 chmod = chmod, 702 chown = chown, 703 dirname = dirname, 704 mkdir_p = mkdir_p, 705 sethostname = sethostname, 706 settimezone = settimezone, 707 adduser = adduser, 708 addgroup = addgroup, 709 addsshkey = addsshkey, 710 update_sshd_config = update_sshd_config, 711 chpasswd = chpasswd, 712 pkg_bootstrap = pkg_bootstrap, 713 install_package = install_package, 714 update_packages = update_packages, 715 upgrade_packages = upgrade_packages, 716 addsudo = addsudo, 717 adddoas = adddoas, 718 addfile = addfile 719} 720 721return n 722