package main import ( "bytes" "encoding/json" "errors" "fmt" "io/ioutil" "net/http" "os" "os/exec" "os/user" osuser "os/user" "path" "strconv" "strings" ) var UsernameFile = ".github-id" var AuthorizedKeys = ".ssh/authorized_keys" const SSHDirPermissions = 0700 const AuthorizedKeysPermissions = 0644 // KeyIdentifier tells which SSH keys belong to this tool. const KeyIdentifier = "Github Key #" // TODO: Return error for missing User in Fetch // TODO: Overwrite keys file only when necessary // TODO: Double check errors // TODO: Update README // TODO: Allow overriding UsernameFile // TODO: Allow AuthorizedKeys locations type LocalUser string func (u *LocalUser) String() string { return string(*u) } type GithubUser string func (u *GithubUser) String() string { return string(*u) } type GithubKey struct { Key string UserName GithubUser ID int } func (g GithubKey) SSHKey() string { return fmt.Sprintf("%s %s's Github Key #%d", g.Key, g.UserName, g.ID) } func (g GithubKey) String() string { return g.SSHKey() } func fetchKeys(username GithubUser) ([]GithubKey, error) { url := fmt.Sprintf("https://api.github.com/users/%s/keys", username) resp, err := http.Get(url) if err != nil { return nil, err } if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { return nil, fmt.Errorf("bad response %s", resp.Status) } var keys []GithubKey err = json.NewDecoder(resp.Body).Decode(&keys) if err != nil { return nil, err } for i, _ := range keys { keys[i].UserName = username } return keys, nil } func lookupGithubName(username LocalUser) (GithubUser, error) { u, err := osuser.Lookup(username.String()) if err != nil { return GithubUser(""), err } idFilePath := path.Join(u.HomeDir, UsernameFile) buf, err := ioutil.ReadFile(idFilePath) if err != nil { return GithubUser(""), err } name := strings.TrimSpace(string(buf)) return GithubUser(name), err } func members(groupName string) ([]LocalUser, error) { group, err := user.LookupGroup(groupName) if err != nil { return nil, err } cmd := exec.Command("getent", "passwd") var stdout bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = nil err = cmd.Run() if err != nil { return nil, err } passwdEntries := strings.Split(string(stdout.Bytes()), "\n") var members []LocalUser for _, entry := range passwdEntries { if len(entry) == 0 { continue } fields := strings.Split(entry, ":") userName := fields[0] systemUser, err := user.Lookup(userName) if err != nil { return nil, err } groupIds, err := systemUser.GroupIds() if err != nil { return nil, err } for _, gid := range groupIds { if gid == group.Gid { members = append(members, LocalUser(userName)) break } } } return members, nil } func syncLocalKeys(user LocalUser, keys ...GithubKey) error { u, err := osuser.Lookup(user.String()) if err != nil { return err } userUid, err := strconv.Atoi(u.Uid) if err != nil { return errors.New("POSIX system expected") } userGid, err := strconv.Atoi(u.Gid) if err != nil { return errors.New("POSIX system expected") } var origKeys []string ak := path.Join(u.HomeDir, AuthorizedKeys) if _, err := os.Stat(ak); err == nil { buf, err := ioutil.ReadFile(ak) if err != nil { return err } origKeys = strings.Split(string(buf), "\n") } // TODO: check duplicate keys var newKeys []string for _, key := range origKeys { if len(key) == 0 { continue } if strings.Contains(key, KeyIdentifier) { continue } newKeys = append(newKeys, key) } for _, key := range keys { newKeys = append(newKeys, key.SSHKey()) } output := strings.Join(newKeys, "\n") if _, err := os.Stat(path.Dir(ak)); os.IsNotExist(err) { dir := path.Dir(ak) err = os.MkdirAll(dir, SSHDirPermissions) if err != nil { return fmt.Errorf("could not create SSH folder: %w", err) } // ignore errors, root should not be required _ = os.Chown(dir, userUid, userGid) } if _, err := os.Stat(path.Dir(ak)); os.IsNotExist(err) { f, err := os.Create(ak) if err != nil { return fmt.Errorf("could not create create AuthorizedKeys file: %w", err) } _ = f.Chown(userUid, userGid) _ = f.Chmod(AuthorizedKeysPermissions) _ = f.Close() } return ioutil.WriteFile(ak, []byte(output), AuthorizedKeysPermissions) } func failOnErr(msg string, err error) { if err != nil { fmt.Fprintf(os.Stderr, "%s: %s\n", msg, err.Error()) os.Exit(1) } } func Fetch(args ...string) { var users []GithubUser for _, user := range args { users = append(users, GithubUser(user)) } if len(args) == 0 { user, err := osuser.Current() failOnErr("could not find current user", err) githubUser, err := lookupGithubName(LocalUser(user.Username)) failOnErr("could not determine GitHub account", err) users = append(users, githubUser) } var allKeys []GithubKey for _, user := range users { keys, err := fetchKeys(user) if err != nil { failOnErr("could not fetch SSH keys", err) } allKeys = append(allKeys, keys...) } for _, key := range allKeys { fmt.Println(key) } } func SyncUsers(args ...string) { var localUsers []LocalUser for _, arg := range args { localUsers = append(localUsers, LocalUser(arg)) } if len(localUsers) == 0 { u, err := osuser.Current() failOnErr("could not find current user", err) localUsers = append(localUsers, LocalUser(u.Username)) } for _, user := range localUsers { githubID, err := lookupGithubName(user) if err != nil { // skip continue } keys, err := fetchKeys(githubID) if err != nil { failOnErr("could not fetch SSH keys", err) } err = syncLocalKeys(user, keys...) if err != nil { failOnErr("could not update AuthorizedKeys file", err) } } } func SyncGroups(args ...string) { var groupNames []string if len(args) == 0 { groupNames = append(groupNames, "users") } var users []LocalUser for _, groupName := range groupNames { groupsUsers, err := members(groupName) failOnErr("vould not determine membership", err) users = append(users, groupsUsers...) } var syncUserArgs []string for _, val := range users { syncUserArgs = append(syncUserArgs, string(val)) } SyncUsers(syncUserArgs...) }