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