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