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