1--- 2-- SPDX-License-Identifier: BSD-2-Clause 3-- 4-- Copyright(c) 2022-2025 Baptiste Daroussin <bapt@FreeBSD.org> 5 6local unistd = require("posix.unistd") 7local sys_stat = require("posix.sys.stat") 8local lfs = require("lfs") 9 10local function decode_base64(input) 11 local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 12 input = string.gsub(input, '[^'..b..'=]', '') 13 14 local result = {} 15 local bits = '' 16 17 -- convert all characters in bits 18 for i = 1, #input do 19 local x = input:sub(i, i) 20 if x == '=' then 21 break 22 end 23 local f = b:find(x) - 1 24 for j = 6, 1, -1 do 25 bits = bits .. (f % 2^j - f % 2^(j-1) > 0 and '1' or '0') 26 end 27 end 28 29 for i = 1, #bits, 8 do 30 local byte = bits:sub(i, i + 7) 31 if #byte == 8 then 32 local c = 0 33 for j = 1, 8 do 34 c = c + (byte:sub(j, j) == '1' and 2^(8 - j) or 0) 35 end 36 table.insert(result, string.char(c)) 37 end 38 end 39 40 return table.concat(result) 41end 42 43local function warnmsg(str, prepend) 44 if not str then 45 return 46 end 47 local tag = "" 48 if prepend ~= false then 49 tag = "nuageinit: " 50 end 51 io.stderr:write(tag .. str .. "\n") 52end 53 54local function errmsg(str, prepend) 55 warnmsg(str, prepend) 56 os.exit(1) 57end 58 59local function chmod(path, mode) 60 local mode = tonumber(mode, 8) 61 local _, err, msg = sys_stat.chmod(path, mode) 62 if err then 63 errmsg("chmod(" .. path .. ", " .. mode .. ") failed: " .. msg) 64 end 65end 66 67local function chown(path, owner, group) 68 local _, err, msg = unistd.chown(path, owner, group) 69 if err then 70 errmsg("chown(" .. path .. ", " .. owner .. ", " .. group .. ") failed: " .. msg) 71 end 72end 73 74local function dirname(oldpath) 75 if not oldpath then 76 return nil 77 end 78 local path = oldpath:gsub("[^/]+/*$", "") 79 if path == "" then 80 return nil 81 end 82 return path 83end 84 85local function mkdir_p(path) 86 if lfs.attributes(path, "mode") ~= nil then 87 return true 88 end 89 local r, err = mkdir_p(dirname(path)) 90 if not r then 91 return nil, err .. " (creating " .. path .. ")" 92 end 93 return lfs.mkdir(path) 94end 95 96local function sethostname(hostname) 97 if hostname == nil then 98 return 99 end 100 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 101 if not root then 102 root = "" 103 end 104 local hostnamepath = root .. "/etc/rc.conf.d/hostname" 105 106 mkdir_p(dirname(hostnamepath)) 107 local f, err = io.open(hostnamepath, "w") 108 if not f then 109 warnmsg("Impossible to open " .. hostnamepath .. ":" .. err) 110 return 111 end 112 f:write('hostname="' .. hostname .. '"\n') 113 f:close() 114end 115 116local function splitlist(list) 117 local ret = {} 118 if type(list) == "string" then 119 for str in list:gmatch("([^, ]+)") do 120 ret[#ret + 1] = str 121 end 122 elseif type(list) == "table" then 123 ret = list 124 else 125 warnmsg("Invalid type " .. type(list) .. ", expecting table or string") 126 end 127 return ret 128end 129 130local function adduser(pwd) 131 if (type(pwd) ~= "table") then 132 warnmsg("Argument should be a table") 133 return nil 134 end 135 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 136 local cmd = "pw " 137 if root then 138 cmd = cmd .. "-R " .. root .. " " 139 end 140 local f = io.popen(cmd .. " usershow " .. pwd.name .. " -7 2> /dev/null") 141 local pwdstr = f:read("*a") 142 f:close() 143 if pwdstr:len() ~= 0 then 144 return pwdstr:match("%a+:.+:%d+:%d+:.*:(.*):.*") 145 end 146 if not pwd.gecos then 147 pwd.gecos = pwd.name .. " User" 148 end 149 if not pwd.homedir then 150 pwd.homedir = "/home/" .. pwd.name 151 end 152 local extraargs = "" 153 if pwd.groups then 154 local list = splitlist(pwd.groups) 155 extraargs = " -G " .. table.concat(list, ",") 156 end 157 -- pw will automatically create a group named after the username 158 -- do not add a -g option in this case 159 if pwd.primary_group and pwd.primary_group ~= pwd.name then 160 extraargs = extraargs .. " -g " .. pwd.primary_group 161 end 162 if not pwd.no_create_home then 163 extraargs = extraargs .. " -m " 164 end 165 if not pwd.shell then 166 pwd.shell = "/bin/sh" 167 end 168 local precmd = "" 169 local postcmd = "" 170 local input = nil 171 if pwd.passwd then 172 input = pwd.passwd 173 postcmd = " -H 0" 174 elseif pwd.plain_text_passwd then 175 input = pwd.plain_text_passwd 176 postcmd = " -h 0" 177 end 178 cmd = precmd .. "pw " 179 if root then 180 cmd = cmd .. "-R " .. root .. " " 181 end 182 cmd = cmd .. "useradd -n " .. pwd.name .. " -M 0755 -w none " 183 cmd = cmd .. extraargs .. " -c '" .. pwd.gecos 184 cmd = cmd .. "' -d '" .. pwd.homedir .. "' -s " .. pwd.shell .. postcmd 185 186 f = io.popen(cmd, "w") 187 if input then 188 f:write(input) 189 end 190 local r = f:close(cmd) 191 if not r then 192 warnmsg("fail to add user " .. pwd.name) 193 warnmsg(cmd) 194 return nil 195 end 196 if pwd.locked then 197 cmd = "pw " 198 if root then 199 cmd = cmd .. "-R " .. root .. " " 200 end 201 cmd = cmd .. "lock " .. pwd.name 202 os.execute(cmd) 203 end 204 return pwd.homedir 205end 206 207local function addgroup(grp) 208 if (type(grp) ~= "table") then 209 warnmsg("Argument should be a table") 210 return false 211 end 212 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 213 local cmd = "pw " 214 if root then 215 cmd = cmd .. "-R " .. root .. " " 216 end 217 local f = io.popen(cmd .. " groupshow " .. grp.name .. " 2> /dev/null") 218 local grpstr = f:read("*a") 219 f:close() 220 if grpstr:len() ~= 0 then 221 return true 222 end 223 local extraargs = "" 224 if grp.members then 225 local list = splitlist(grp.members) 226 extraargs = " -M " .. table.concat(list, ",") 227 end 228 cmd = "pw " 229 if root then 230 cmd = cmd .. "-R " .. root .. " " 231 end 232 cmd = cmd .. "groupadd -n " .. grp.name .. extraargs 233 local r = os.execute(cmd) 234 if not r then 235 warnmsg("fail to add group " .. grp.name) 236 warnmsg(cmd) 237 return false 238 end 239 return true 240end 241 242local function addsshkey(homedir, key) 243 local chownak = false 244 local chowndotssh = false 245 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 246 if root then 247 homedir = root .. "/" .. homedir 248 end 249 local ak_path = homedir .. "/.ssh/authorized_keys" 250 local dotssh_path = homedir .. "/.ssh" 251 local dirattrs = lfs.attributes(ak_path) 252 if dirattrs == nil then 253 chownak = true 254 dirattrs = lfs.attributes(dotssh_path) 255 if dirattrs == nil then 256 assert(lfs.mkdir(dotssh_path)) 257 chowndotssh = true 258 dirattrs = lfs.attributes(homedir) 259 end 260 end 261 262 local f = io.open(ak_path, "a") 263 if not f then 264 warnmsg("impossible to open " .. ak_path) 265 return 266 end 267 f:write(key .. "\n") 268 f:close() 269 if chownak then 270 chmod(ak_path, "0600") 271 chown(ak_path, dirattrs.uid, dirattrs.gid) 272 end 273 if chowndotssh then 274 chmod(dotssh_path, "0700") 275 chown(dotssh_path, dirattrs.uid, dirattrs.gid) 276 end 277end 278 279local function addsudo(pwd) 280 local chmodsudoersd = false 281 local chmodsudoers = false 282 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 283 local sudoers_dir = "/usr/local/etc/sudoers.d" 284 if root then 285 sudoers_dir= root .. sudoers_dir 286 end 287 local sudoers = sudoers_dir .. "/90-nuageinit-users" 288 local sudoers_attr = lfs.attributes(sudoers) 289 if sudoers_attr == nil then 290 chmodsudoers = true 291 local dirattrs = lfs.attributes(sudoers_dir) 292 if dirattrs == nil then 293 local r, err = mkdir_p(sudoers_dir) 294 if not r then 295 return nil, err .. " (creating " .. sudoers_dir .. ")" 296 end 297 chmodsudoersd = true 298 end 299 end 300 local f = io.open(sudoers, "a") 301 if not f then 302 warnmsg("impossible to open " .. sudoers) 303 return 304 end 305 if type(pwd.sudo) == "string" then 306 f:write(pwd.name .. " " .. pwd.sudo .. "\n") 307 elseif type(pwd.sudo) == "table" then 308 for _, str in ipairs(pwd.sudo) do 309 f:write(pwd.name .. " " .. str .. "\n") 310 end 311 end 312 f:close() 313 if chmodsudoers then 314 chmod(sudoers, "0640") 315 end 316 if chmodsudoersd then 317 chmod(sudoers, "0740") 318 end 319end 320 321local function update_sshd_config(key, value) 322 local sshd_config = "/etc/ssh/sshd_config" 323 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 324 if root then 325 sshd_config = root .. sshd_config 326 end 327 local f = assert(io.open(sshd_config, "r+")) 328 local tgt = assert(io.open(sshd_config .. ".nuageinit", "w")) 329 local found = false 330 local pattern = "^%s*"..key:lower().."%s+(%w+)%s*#?.*$" 331 while true do 332 local line = f:read() 333 if line == nil then break end 334 local _, _, val = line:lower():find(pattern) 335 if val then 336 found = true 337 if val == value then 338 assert(tgt:write(line .. "\n")) 339 else 340 assert(tgt:write(key .. " " .. value .. "\n")) 341 end 342 else 343 assert(tgt:write(line .. "\n")) 344 end 345 end 346 if not found then 347 assert(tgt:write(key .. " " .. value .. "\n")) 348 end 349 assert(f:close()) 350 assert(tgt:close()) 351 os.rename(sshd_config .. ".nuageinit", sshd_config) 352end 353 354local function exec_change_password(user, password, type, expire) 355 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 356 local cmd = "pw " 357 if root then 358 cmd = cmd .. "-R " .. root .. " " 359 end 360 local postcmd = " -H 0" 361 local input = password 362 if type ~= nil and type == "text" then 363 postcmd = " -h 0" 364 else 365 if password == "RANDOM" then 366 input = nil 367 postcmd = " -w random" 368 end 369 end 370 cmd = cmd .. "usermod " .. user .. postcmd 371 if expire then 372 cmd = cmd .. " -p 1" 373 else 374 cmd = cmd .. " -p 0" 375 end 376 local f = io.popen(cmd .. " >/dev/null", "w") 377 if input then 378 f:write(input) 379 end 380 -- ignore stdout to avoid printing the password in case of random password 381 local r = f:close(cmd) 382 if not r then 383 warnmsg("fail to change user password ".. user) 384 warnmsg(cmd) 385 end 386end 387 388local function change_password_from_line(line, expire) 389 local user, password = line:match("%s*(%w+):(%S+)%s*") 390 local type = nil 391 if user and password then 392 if password == "R" then 393 password = "RANDOM" 394 end 395 if not password:match("^%$%d+%$%w+%$") then 396 if password ~= "RANDOM" then 397 type = "text" 398 end 399 end 400 exec_change_password(user, password, type, expire) 401 end 402end 403 404local function chpasswd(obj) 405 if type(obj) ~= "table" then 406 warnmsg("Invalid chpasswd entry, expecting an object") 407 return 408 end 409 local expire = false 410 if obj.expire ~= nil then 411 if type(obj.expire) == "boolean" then 412 expire = obj.expire 413 else 414 warnmsg("Invalid type for chpasswd.expire, expecting a boolean, got a ".. type(obj.expire)) 415 end 416 end 417 if obj.users ~= nil then 418 if type(obj.users) ~= "table" then 419 warnmsg("Invalid type for chpasswd.users, expecting a list, got a ".. type(obj.users)) 420 goto list 421 end 422 for _, u in ipairs(obj.users) do 423 if type(u) ~= "table" then 424 warnmsg("Invalid chpasswd.users entry, expecting an object, got a " .. type(u)) 425 goto next 426 end 427 if not u.name then 428 warnmsg("Invalid entry for chpasswd.users: missing 'name'") 429 goto next 430 end 431 if not u.password then 432 warnmsg("Invalid entry for chpasswd.users: missing 'password'") 433 goto next 434 end 435 exec_change_password(u.name, u.password, u.type, expire) 436 ::next:: 437 end 438 end 439 ::list:: 440 if obj.list ~= nil then 441 warnmsg("chpasswd.list is deprecated consider using chpasswd.users") 442 if type(obj.list) == "string" then 443 for line in obj.list:gmatch("[^\n]+") do 444 change_password_from_line(line, expire) 445 end 446 elseif type(obj.list) == "table" then 447 for _, u in ipairs(obj.list) do 448 change_password_from_line(u, expire) 449 end 450 end 451 end 452end 453 454local function pkg_bootstrap() 455 if os.getenv("NUAGE_RUN_TESTS") then 456 return true 457 end 458 if os.execute("pkg -N 2>/dev/null") then 459 return true 460 end 461 print("Bootstrapping pkg") 462 return os.execute("env ASSUME_ALWAYS_YES=YES pkg bootstrap") 463end 464 465local function install_package(package) 466 if package == nil then 467 return true 468 end 469 local install_cmd = "pkg install -y " .. package 470 local test_cmd = "pkg info -q " .. package 471 if os.getenv("NUAGE_RUN_TESTS") then 472 print(install_cmd) 473 print(test_cmd) 474 return true 475 end 476 if os.execute(test_cmd) then 477 return true 478 end 479 return os.execute(install_cmd) 480end 481 482local function run_pkg_cmd(subcmd) 483 local cmd = "pkg " .. subcmd .. " -y" 484 if os.getenv("NUAGE_RUN_TESTS") then 485 print(cmd) 486 return true 487 end 488 return os.execute(cmd) 489end 490local function update_packages() 491 return run_pkg_cmd("update") 492end 493 494local function upgrade_packages() 495 return run_pkg_cmd("upgrade") 496end 497 498local function addfile(file, defer) 499 if type(file) ~= "table" then 500 return false, "Invalid object" 501 end 502 if defer and not file.defer then 503 return true 504 end 505 if not defer and file.defer then 506 return true 507 end 508 if not file.path then 509 return false, "No path provided for the file to write" 510 end 511 local content = nil 512 if file.content then 513 if file.encoding then 514 if file.encoding == "b64" or file.encoding == "base64" then 515 content = decode_base64(file.content) 516 else 517 return false, "Unsupported encoding: " .. file.encoding 518 end 519 else 520 content = file.content 521 end 522 end 523 local mode = "w" 524 if file.append then 525 mode = "a" 526 end 527 528 local root = os.getenv("NUAGE_FAKE_ROOTDIR") 529 if not root then 530 root = "" 531 end 532 local filepath = root .. file.path 533 local f = assert(io.open(filepath, mode)) 534 if content then 535 f:write(content) 536 end 537 f:close() 538 if file.permissions then 539 chmod(filepath, file.permissions) 540 end 541 if file.owner then 542 local owner, group = string.match(file.owner, "([^:]+):([^:]+)") 543 if not owner then 544 owner = file.owner 545 end 546 chown(filepath, owner, group) 547 end 548 return true 549end 550 551local n = { 552 warn = warnmsg, 553 err = errmsg, 554 chmod = chmod, 555 chown = chown, 556 dirname = dirname, 557 mkdir_p = mkdir_p, 558 sethostname = sethostname, 559 adduser = adduser, 560 addgroup = addgroup, 561 addsshkey = addsshkey, 562 update_sshd_config = update_sshd_config, 563 chpasswd = chpasswd, 564 pkg_bootstrap = pkg_bootstrap, 565 install_package = install_package, 566 update_packages = update_packages, 567 upgrade_packages = upgrade_packages, 568 addsudo = addsudo, 569 addfile = addfile 570} 571 572return n 573