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