1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615
|
package main
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/snapcore/snapd/dirs"
"github.com/snapcore/snapd/logger"
"github.com/snapcore/snapd/strutil"
)
type user struct {
name string
uid int
gid int
groups []string
shell string
// these are the original lines from their respective files
passwdEntry string
shadowEntry string
}
var (
// groupsToImport is the list of groups that will have their users imported
// from the hybrid system into the ephemeral recovery system.
groupsToImport = []string{"sudo", "admin"}
// defaultLoginShells is the list of shells that we will allow for logging
// into the system if the /etc/shells file is not readable.
defaultLoginShells = []string{"/bin/bash", "/bin/sh"}
)
// importHybridUserData merges users and groups from the hybrid rootfs with the
// users and groups from the base snap. The merged login files are written into
// [dirs.SnapRunDir]/hybrid-users. As an attempt to only import users that have
// elevated privileges, only users from the sudo and admin groups are imported.
func importHybridUserData(hybridRoot, baseRoot string) error {
outputDir := filepath.Join(dirs.SnapRunDir, "hybrid-users")
if err := os.MkdirAll(outputDir, 0755); err != nil {
return err
}
return mergeAndWriteLoginFiles(hybridRoot, baseRoot, groupsToImport, outputDir)
}
func userFilter(
targetGroups []string,
baseUsers map[string]user,
baseGroups map[string]group,
loginShells []string,
) func(user) bool {
return func(u user) bool {
// we always want to import the root user
if u.uid == 0 {
return true
}
// don't consider any system users or users that have a system group as
// their primary group
if u.uid < 1000 || u.gid < 1000 {
return false
}
if _, ok := baseUsers[u.name]; ok {
logger.Noticef("skipping importing user %q because it shares a name with a user in the base", u.name)
return false
}
if checkForUIDConflict(u.uid, baseUsers) {
logger.Noticef("skipping importing user %q with UID %d because it conflicts with a user in the base", u.name, u.uid)
return false
}
if checkForGIDConflict(u.gid, baseGroups) {
logger.Noticef("skipping importing user %q with GID %d because it conflicts with a group in the base", u.name, u.gid)
return false
}
if u.shell != "" && !strutil.ListContains(loginShells, u.shell) {
logger.Noticef("skipping importing user %q with shell %q because it is not in the list of valid login shells", u.name, u.shell)
return false
}
// only consider users that are in the groups that we're specifically
// importing
for _, tg := range targetGroups {
if strutil.ListContains(u.groups, tg) {
return true
}
}
return false
}
}
func checkForGIDConflict(gid int, groups map[string]group) bool {
for _, g := range groups {
if g.gid == gid {
return true
}
}
return false
}
func checkForUIDConflict(uid int, users map[string]user) bool {
for _, u := range users {
if u.uid == uid {
return true
}
}
return false
}
func groupFilter(importUsers map[string]user, baseGroups map[string]group) func(group) bool {
return func(g group) bool {
// always import the root group
if g.gid == 0 {
return true
}
if _, ok := baseGroups[g.name]; ok {
logger.Noticef("skipping importing group %q because it shares a name with a group in the base", g.name)
return false
}
// in addition to importing the users that are in the given groups, we also
// import the primary groups that those users are in. these will most likely
// all be unique, but there is a chance that users share a primary group.
for _, u := range importUsers {
if u.gid == g.gid {
return true
}
}
return false
}
}
// mergeAndWriteLoginFiles considers the etc/passwd, etc/shadow, etc/group, and
// etc/gshadow files from the given importRoot and baseRoot directories, merges
// them together, and writes the merged files into the given outputDir.
//
// Only non-system (uid and gid < 1000) users that are members of any of the
// groups in the targetGroups slice are imported from the importRoot directory.
// All users and groups from the baseRoot are imported. As a special case, the
// root user is always imported from importRoot.
//
// If there are any conflicts between the users and groups in the importRoot and
// baseRoot directories, the importRoot users and groups are used.
func mergeAndWriteLoginFiles(importRoot, baseRoot string, targetGroups []string, outputDir string) error {
baseUsers, err := parseUsers(baseRoot, nil)
if err != nil {
return err
}
baseGroups, err := parseGroups(baseRoot, nil)
if err != nil {
return err
}
allowedLoginShells := parseShellsWithDefaults(importRoot, defaultLoginShells)
importUsers, err := parseUsers(importRoot, userFilter(
targetGroups,
baseUsers,
baseGroups,
allowedLoginShells,
))
if err != nil {
return err
}
importGroups, err := parseGroups(importRoot, groupFilter(importUsers, baseGroups))
if err != nil {
return err
}
// update the passwd and shadow files to contain the lines for the users
// that we're importing
err = mergeAndWriteUserFiles(baseUsers, importUsers, outputDir)
if err != nil {
return err
}
// update the group and gshadow files to add our users to any existing
// groups, and add any new primary groups that we're importing.
if err := mergeAndWriteGroupFiles(baseGroups, importUsers, importGroups, outputDir); err != nil {
return err
}
return nil
}
// mergeAndWriteUserFiles merges the base users with the given users to be
// imported. The caller must ensure that the UIDs and GIDs of any of the given
// users do not conflict with any of the given base users. The merged files are
// written into the given output directory.
func mergeAndWriteUserFiles(baseUsers map[string]user, importUsers map[string]user, outputDir string) error {
var passwdBuffer bytes.Buffer
var shadowBuffer bytes.Buffer
for name, user := range baseUsers {
if name != user.name {
return fmt.Errorf("internal error: user entry inconsistent with parsed data")
}
if _, ok := importUsers[name]; ok {
// as a special case, we replace the root user in the base with the
// root user from the hybrid rootfs. all other conflicting users are
// disallowed.
if name == "root" {
continue
}
return fmt.Errorf("internal error: cannot import user %q that conflicts with a user in the base", name)
}
passwdBuffer.WriteString(user.passwdEntry + "\n")
if user.shadowEntry != "" {
shadowBuffer.WriteString(user.shadowEntry + "\n")
}
}
for _, user := range importUsers {
parts := strings.Split(user.passwdEntry, ":")
// we're going to ignore the user's shell, since it could be anything,
// we replace it with /bin/bash, since this should give a good default
// experience and should always be available in the base.
//
// note that last field (the user's login shell) is optional, and the
// trailing colon might not be present
switch len(parts) {
case 6:
parts = append(parts, "/bin/bash")
case 7:
parts[6] = "/bin/bash"
default:
return fmt.Errorf("internal error: passwd entry inconsistent with parsed data")
}
passwdBuffer.WriteString(strings.Join(parts, ":") + "\n")
if user.shadowEntry != "" {
shadowBuffer.WriteString(user.shadowEntry + "\n")
}
}
destinationPasswd := filepath.Join(outputDir, "passwd")
if err := os.WriteFile(destinationPasswd, passwdBuffer.Bytes(), 0644); err != nil {
return err
}
destinationShadow := filepath.Join(outputDir, "shadow")
return os.WriteFile(destinationShadow, shadowBuffer.Bytes(), 0600)
}
// mergeAndWriteUserFiles merges the given base groups with the given users and
// groups that are to be imported. The caller must ensure that UIDs and GIDs of
// any of the given groups and users to import do not conflict with any of the
// existing groups and users in the base groups. The merged files are written
// into the given output directory.
func mergeAndWriteGroupFiles(baseGroups map[string]group, importUsers map[string]user, importGroups map[string]group, outputDir string) error {
groupsToNewUsers := make(map[string][]string)
for _, user := range importUsers {
for _, group := range user.groups {
groupsToNewUsers[group] = append(groupsToNewUsers[group], user.name)
}
}
var groupBuffer bytes.Buffer
var gshadowBuffer bytes.Buffer
for name, group := range baseGroups {
if name != group.name {
return errors.New("internal error: group entry inconsistent with parsed data")
}
if _, ok := importGroups[name]; ok {
// as a special case, we replace the root group in the base with the
// root user from the hybrid rootfs. all other conflicting groups
// are disallowed.
if name == "root" {
continue
}
return fmt.Errorf("internal error: cannot import group %q that conflicts with a group in the base", name)
}
// we combine the users that are already in the group with the new users
// that we're importing
usersInGroup := unique(append(group.users, groupsToNewUsers[name]...))
sort.Strings(usersInGroup)
usersPart := strings.Join(usersInGroup, ",")
parts := strings.Split(group.groupEntry, ":")
fmt.Fprintf(&groupBuffer, "%s:%s:%s:%s\n", parts[0], parts[1], parts[2], usersPart)
// if this group has a gshadow entry, we need to update the list of
// users there as well. not having a gshadow entry is fine, though.
if group.gshadowEntry == "" {
continue
}
gshadowParts := strings.Split(group.gshadowEntry, ":")
if len(gshadowParts) != 4 {
continue
}
fmt.Fprintf(&gshadowBuffer, "%s:%s:%s:%s\n", gshadowParts[0], gshadowParts[1], gshadowParts[2], usersPart)
}
// add the group entries for the users that we're importing. note that we
// don't need to mess with the users in these groups, since they're coming
// directly from the system we're importing from
for _, g := range importGroups {
groupBuffer.WriteString(g.groupEntry + "\n")
if g.gshadowEntry != "" {
gshadowBuffer.WriteString(g.gshadowEntry + "\n")
}
}
destinationGroup := filepath.Join(outputDir, "group")
if err := os.WriteFile(destinationGroup, groupBuffer.Bytes(), 0644); err != nil {
return err
}
destinationGShadow := filepath.Join(outputDir, "gshadow")
return os.WriteFile(destinationGShadow, gshadowBuffer.Bytes(), 0600)
}
// unique returns a slice with unique entries; the provided slice is modified
// and should not be re-used.
func unique(slice []string) []string {
seen := make(map[string]bool)
current := 0
for _, entry := range slice {
if _, ok := seen[entry]; ok {
continue
}
seen[entry] = true
slice[current] = entry
current++
}
return slice[:current]
}
// parseUsers parses users from the given root directory, using the etc/passwd,
// etc/shadow, and etc/group files. The caller can provide a filter to omit some
// of the returned users by returning false from the given function.
func parseUsers(root string, filter func(user) bool) (map[string]user, error) {
if filter == nil {
filter = func(user) bool { return true }
}
passwdEntries, err := entriesByName(filepath.Join(root, "etc/passwd"))
if err != nil {
return nil, err
}
shadowEntries, err := entriesByName(filepath.Join(root, "etc/shadow"))
if err != nil {
return nil, err
}
userToGroups, err := parseGroupsForUser(filepath.Join(root, "etc/group"))
if err != nil {
return nil, err
}
users := make(map[string]user)
for name, entry := range passwdEntries {
parts := strings.Split(entry, ":")
if len(parts) != 6 && len(parts) != 7 {
logger.Noticef("skipping importing user with invalid entry: %v", entry)
continue
}
if name != parts[0] {
return nil, errors.New("internal error: passwd entry inconsistent with parsed data")
}
uid, err := strconv.Atoi(parts[2])
if err != nil {
logger.Noticef("skipping importing user %q with invalid uid: %v", name, err)
continue
}
gid, err := strconv.Atoi(parts[3])
if err != nil {
logger.Noticef("skipping importing user %q with invalid gid: %v", name, err)
continue
}
var shell string
if len(parts) == 7 {
shell = parts[6]
}
u := user{
name: name,
uid: uid,
gid: gid,
groups: userToGroups[name],
shell: shell,
passwdEntry: entry,
shadowEntry: shadowEntries[name],
}
if !filter(u) {
continue
}
users[name] = u
}
return users, nil
}
type group struct {
name string
gid int
users []string
groupEntry string
gshadowEntry string
}
// parseGroups parses groups from the given root directory, using the etc/group
// and etc/gshadow files. The caller can provide a filter to omit some of the
// returned groups by returning false from the given function.
func parseGroups(root string, filter func(group) bool) (map[string]group, error) {
if filter == nil {
filter = func(group) bool { return true }
}
groupEntries, err := entriesByName(filepath.Join(root, "etc/group"))
if err != nil {
return nil, err
}
gshadowEntries, err := entriesByName(filepath.Join(root, "etc/gshadow"))
if err != nil {
return nil, err
}
groups := make(map[string]group)
for name, entry := range groupEntries {
parts := strings.Split(entry, ":")
if len(parts) != 4 {
logger.Noticef("skipping importing group with invalid entry: %s", entry)
continue
}
if name != parts[0] {
return nil, errors.New("internal error: group entry inconsistent with parsed data")
}
gid, err := strconv.Atoi(parts[2])
if err != nil {
logger.Noticef("skipping importing group %q with invalid gid: %v", name, err)
continue
}
var users []string
if len(parts[3]) > 0 {
users = strings.Split(parts[3], ",")
}
g := group{
name: name,
gid: gid,
users: users,
groupEntry: entry,
gshadowEntry: gshadowEntries[name],
}
if !filter(g) {
continue
}
groups[name] = g
}
return groups, nil
}
// parseGroupsForUser reads the contents of a file in the style of /etc/group
// and returns a mapping of users in the file to the groups that each user is a
// member of.
func parseGroupsForUser(path string) (usersToGroups map[string][]string, err error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
usersToGroups = make(map[string][]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.Split(line, ":")
if len(parts) != 4 {
continue
}
group := parts[0]
if parts[3] == "" {
continue
}
users := strings.Split(parts[3], ",")
for _, user := range users {
usersToGroups[user] = append(usersToGroups[user], group)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return usersToGroups, nil
}
func parseShellsWithDefaults(root string, defaults []string) []string {
shells, err := parseShells(root)
if err != nil {
logger.Noticef("cannot parse %q, using default login shells: %v", filepath.Join(root, "etc/shells"), err)
return defaults
}
return shells
}
// parseShells parses a file in the format of /etc/shells and returns a list of
// the valid login shells.
func parseShells(root string) ([]string, error) {
f, err := os.Open(filepath.Join(root, "etc/shells"))
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
var shells []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// split the line into the shell and the comment
shell, _, _ := strings.Cut(line, "#")
shell = strings.TrimSpace(shell)
// if the line was entirely a comment, then shell will be empty
if shell != "" {
shells = append(shells, shell)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return shells, nil
}
// entriesByName reads the contents of a file in the style of /etc/passwd and
// returns a mapping of the value of the first column of each line in the file
// to the entire line.
//
// Parsing "user:x:1000:1000::/home/user:/bin/bash" results in:
// "user" -> "user:x:1000:1000::/home/user:/bin/bash"
func entriesByName(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
entries := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
name, _, ok := strings.Cut(line, ":")
if !ok {
continue
}
entries[name] = line
}
if err := scanner.Err(); err != nil {
return nil, err
}
return entries, nil
}
|