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