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