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