From b3b2ae2ae43e3cafdb856eaf6233878a827cb421 Mon Sep 17 00:00:00 2001 From: GRMrGecko Date: Sun, 13 Aug 2023 21:27:25 -0500 Subject: [PATCH] Initial commit --- License.txt | 19 ++++ README.md | 27 +++++ go.mod | 3 + test/group | 6 + test/invalid-group | 5 + test/invalid-passwd | 4 + test/passwd | 5 + unixAccounts.go | 257 +++++++++++++++++++++++++++++++++++++++++++ unixAccounts_test.go | 72 ++++++++++++ 9 files changed, 398 insertions(+) create mode 100644 License.txt create mode 100644 README.md create mode 100644 go.mod create mode 100644 test/group create mode 100644 test/invalid-group create mode 100644 test/invalid-passwd create mode 100644 test/passwd create mode 100644 unixAccounts.go create mode 100644 unixAccounts_test.go diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..3c9e3c8 --- /dev/null +++ b/License.txt @@ -0,0 +1,19 @@ +Copyright (c) 2023 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..735cddb --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# go-unixaccounts +A simple UNIX account information parser of /etc/passwd and /etc/group for GoLang. + +## Install +go get github.com/grmrgecko/go-unixaccounts + +## Example +```go +import ( + "fmt" + "github.com/grmrgecko/go-unixaccounts" +) + +func main() { + accounts := UNIXAccounts.NewUNIXAccounts() + + user := accounts.UserWithName("root") + groups := accounts.UserMemberOf(user) + + var groupNames []string + for _, group := range groups { + groupNames = append(groupNames, group.Name) + } + + fmt.Println("Found groups root is a member of:", groupNames) +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f4a55d0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/grmrgecko/go-unixaccounts + +go 1.20 diff --git a/test/group b/test/group new file mode 100644 index 0000000..2ab84d0 --- /dev/null +++ b/test/group @@ -0,0 +1,6 @@ +root:x:0: +bin:x:1: +daemon:x:2: +cdrom:x:11:test,root +# Test group comment +test:x:1001: diff --git a/test/invalid-group b/test/invalid-group new file mode 100644 index 0000000..5bc0839 --- /dev/null +++ b/test/invalid-group @@ -0,0 +1,5 @@ +root:x:0: +bin:x:1::test +daemon:x:2: +cdrom:x:11:test,root +test:x:1001: diff --git a/test/invalid-passwd b/test/invalid-passwd new file mode 100644 index 0000000..0a0988e --- /dev/null +++ b/test/invalid-passwd @@ -0,0 +1,4 @@ +root:x:0:0:root@ipa-node4-p01.lan3.us-midwest-2.lwinternal.com:/root:/bin/bash +bin:x:1:1:bin:/bin:/sbin/nologin +daemon:x:2:2:daemon:/sbin:/sbin/nologin:hi +test:x:1000:1001:Test User:/home/test:/bin/bash diff --git a/test/passwd b/test/passwd new file mode 100644 index 0000000..df67b8f --- /dev/null +++ b/test/passwd @@ -0,0 +1,5 @@ +root:x:0:0:root@ipa-node4-p01.lan3.us-midwest-2.lwinternal.com:/root:/bin/bash +bin:x:1:1:bin:/bin:/sbin/nologin +daemon:x:2:2:daemon:/sbin:/sbin/nologin +# Test user comment +test:x:1000:1001:Test User:/home/test:/bin/bash diff --git a/unixAccounts.go b/unixAccounts.go new file mode 100644 index 0000000..beafaf4 --- /dev/null +++ b/unixAccounts.go @@ -0,0 +1,257 @@ +package UNIXAccounts + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Base accounts structure and configuration. +type UNIXAccounts struct { + Groups []*UNIXGroup + Users []*UNIXUser + PasswdPath string + GroupPath string +} + +// Read the /etc/group and /etc/passwd files to parse information. +func NewUNIXAccounts() (*UNIXAccounts, error) { + u := &UNIXAccounts{ + PasswdPath: "/etc/passwd", + GroupPath: "/etc/group", + } + err := u.Parse() + if err != nil { + return nil, err + } + return u, nil +} + +// Group data structure. +type UNIXGroup struct { + Name string + ID int + Users []string +} + +// User data structure. +type UNIXUser struct { + Name string + ID int + GID int + FullName string + HomeDir string + Shell string + Disabled bool +} + +// Parse: Parse unix accounts and groups. +func (u *UNIXAccounts) Parse() error { + // Remove any previously parsed users to re-parse. + u.Groups = nil + u.Users = nil + + // Open the group file. + groupFile, err := os.Open(u.GroupPath) + if err != nil { + return err + } + defer groupFile.Close() + + scanner := bufio.NewScanner(groupFile) + scanner.Split(bufio.ScanLines) + lineCount := 0 + for scanner.Scan() { + // Read a line. + line := scanner.Text() + + // Ignore comments. + if line[0] == '#' { + continue + } + + // Fields are separated with a :. + fields := strings.Split(line, ":") + + // Groups should have 4 fields. Nothing more, nothing less. + if len(fields) != 4 { + return fmt.Errorf("unexpected field count in group file on line %d", lineCount) + } + + // Parse information. + group := new(UNIXGroup) + group.Name = fields[0] + group.ID, _ = strconv.Atoi(fields[2]) + group.Users = strings.Split(fields[3], ",") + + // Add group to array. + u.Groups = append(u.Groups, group) + + // Increment line count. + lineCount++ + } + + // Open the user file. + passwdFile, err := os.Open(u.PasswdPath) + if err != nil { + return err + } + defer passwdFile.Close() + + scanner = bufio.NewScanner(passwdFile) + scanner.Split(bufio.ScanLines) + lineCount = 0 + for scanner.Scan() { + // Read a line. + line := scanner.Text() + + // Ignore comments. + if line[0] == '#' { + continue + } + + // Fields are separated with a :. + fields := strings.Split(line, ":") + + // Users have 7 fields. No more or less. + if len(fields) != 7 { + return fmt.Errorf("unexpected field count in passwd file on line %d", lineCount) + } + + // Prase information. + user := new(UNIXUser) + user.Name = fields[0] + user.ID, _ = strconv.Atoi(fields[2]) + user.GID, _ = strconv.Atoi(fields[3]) + user.FullName = fields[4] + user.HomeDir = filepath.Clean(fields[5]) + user.Shell = fields[6] + + // A user is disabled if their shell is set to nologin or false. Users with no shell should also be disabled. + user.Disabled = false + if strings.Contains(user.Shell, "nologin") { + user.Disabled = true + } + if strings.Contains(user.Shell, "false") { + user.Disabled = true + } + if user.Shell == "" { + user.Disabled = true + } + + // Add user to array. + u.Users = append(u.Users, user) + + // Increment line count. + lineCount++ + } + return nil +} + +// Find user info for ID. +func (u *UNIXAccounts) UserWithID(id int) *UNIXUser { + for _, user := range u.Users { + if user.ID == id { + return user + } + } + return nil +} + +// Find user info for name. +func (u *UNIXAccounts) UserWithName(name string) *UNIXUser { + for _, user := range u.Users { + if user.Name == name { + return user + } + } + return nil +} + +// Find group info for ID. +func (u *UNIXAccounts) GroupWithID(id int) *UNIXGroup { + for _, group := range u.Groups { + if group.ID == id { + return group + } + } + return nil +} + +// Find group info for name. +func (u *UNIXAccounts) GroupWithName(name string) *UNIXGroup { + for _, group := range u.Groups { + if group.Name == name { + return group + } + } + return nil +} + +// Get all user accounts which are members of a group. +func (u *UNIXAccounts) UsersInGroup(group *UNIXGroup) []*UNIXUser { + var users []*UNIXUser + // Users with the Group ID set to the group's ID are a member. + for _, user := range u.Users { + if user.GID == group.ID { + users = append(users, user) + } + } + // Find user info for each member. + for _, name := range group.Users { + user := u.UserWithName(name) + if user == nil { + continue + } + // If the member was added previously, we do not want duplicates. + alreadyExists := false + for _, usr := range users { + if usr == user { + alreadyExists = true + break + } + } + if !alreadyExists { + // The member is not a duplicate, so we add it to the array. + users = append(users, user) + } + } + return users +} + +// List of groups a user is a member of. +func (u *UNIXAccounts) UserMemberOf(user *UNIXUser) []*UNIXGroup { + var groups []*UNIXGroup + + // Look at each group and check if this user is a member. + for _, group := range u.Groups { + // If the GID of the user is this group, add it. + if group.ID == user.GID { + groups = append(groups, group) + continue + } + + // Check each user assigned to this group and add the group if the user matches. + for _, thisUser := range group.Users { + if thisUser == user.Name { + // If the group was added previously, we do not want duplicates. + alreadyExists := false + for _, grp := range groups { + if grp == group { + alreadyExists = true + break + } + } + if !alreadyExists { + // The group is not a duplicate, so we add it to the array. + groups = append(groups, group) + } + } + } + } + + return groups +} diff --git a/unixAccounts_test.go b/unixAccounts_test.go new file mode 100644 index 0000000..2f4b407 --- /dev/null +++ b/unixAccounts_test.go @@ -0,0 +1,72 @@ +package UNIXAccounts + +import "testing" + +func TestAccounts(t *testing.T) { + u := &UNIXAccounts{ + PasswdPath: "test/passwd", + GroupPath: "test/group", + } + err := u.Parse() + if err != nil { + t.Errorf("error parsing: %s", err) + } + + user := u.UserWithID(2) + if user == nil || user.Name != "daemon" { + t.Error("unexpected user found by id") + } + + user = u.UserWithName("test") + if user == nil || user.ID != 1000 { + t.Error("unexpected user found by name") + } + + group := u.GroupWithID(1) + if group == nil || group.Name != "bin" { + t.Error("unexpected group found by id") + } + + group = u.GroupWithName("cdrom") + if group == nil || group.ID != 11 { + t.Error("unexpected group found by name") + } + + users := u.UsersInGroup(group) + if len(users) != 2 { + t.Error("unexpected user count found") + } + for _, usr := range users { + if usr.Name != "root" && usr.Name != "test" { + t.Errorf("found unexpected user in group: %s", usr.Name) + } + } + + groups := u.UserMemberOf(user) + if len(groups) != 2 { + t.Error("unexpected group count found") + } + for _, grp := range groups { + if grp.Name != "test" && grp.Name != "cdrom" { + t.Errorf("found unexpected group found by user: %s", grp.Name) + } + } + + u = &UNIXAccounts{ + PasswdPath: "test/invalid-passwd", + GroupPath: "test/group", + } + err = u.Parse() + if err == nil { + t.Error("expected parse to fail, but it succeeded.") + } + + u = &UNIXAccounts{ + PasswdPath: "test/passwd", + GroupPath: "test/invalid-group", + } + err = u.Parse() + if err == nil { + t.Error("expected parse to fail, but it succeeded.") + } +}