From 5a7b87d1abfec09384137b7bf5cf9da906d7dd82 Mon Sep 17 00:00:00 2001 From: Slack Coder Date: Mon, 7 Jun 2021 13:43:31 -0500 Subject: initial commit --- README.md | 26 ++++++ github.go | 281 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 5 ++ main.go | 80 ++++++++++++++++++ 4 files changed, 392 insertions(+) create mode 100644 README.md create mode 100644 github.go create mode 100644 go.mod create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..bba273d --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +Github SSH Key +-------------- + +Sync local user's SSH AuthorizedKey with ones from their Github Account. + +``` +Usage: github-ssh-key + +Subcommands: + commands list all command names + fetch fetch SSH keys for the given Github Users + flags describe all known top-level flags + help describe subcommands and their syntax + sync-group Sync SSH keys for the given groups. + sync-users Sync SSH keys for the given users. + + +Use "github-ssh-key flags" for a list of top-level flags +``` + +The tool expects a user's Github account to be defined in the '.github-id' file in their home directory. + +### Related or Similar Projects + + - [ssh-keys-from-remotes](https://github.com/liamdawson/ssh-keys-from-remotes) + - [Monkeysphere](https://web.monkeysphere.info) diff --git a/github.go b/github.go new file mode 100644 index 0000000..ec478d4 --- /dev/null +++ b/github.go @@ -0,0 +1,281 @@ +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...) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..09c240c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.server.ky/slackcoder/github-ssh-key + +go 1.15 + +require github.com/google/subcommands v1.2.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..511736a --- /dev/null +++ b/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "flag" + "os" + + "github.com/google/subcommands" +) + +type FetchCmd struct{} + +func (s *FetchCmd) Name() string { return "fetch" } + +func (s *FetchCmd) Synopsis() string { return "fetch SSH keys for the given Github Users" } + +func (s *FetchCmd) Usage() string { + return `fetch [user 1] [user 2]` +} + +func (s *FetchCmd) SetFlags(_ *flag.FlagSet) {} + +func (s *FetchCmd) Execute( + _ context.Context, + f *flag.FlagSet, _ ...interface{}, +) subcommands.ExitStatus { + Fetch(f.Args()...) + return subcommands.ExitSuccess +} + +type SyncUsersCmd struct{} + +func (s *SyncUsersCmd) Name() string { return "sync-users" } + +func (s *SyncUsersCmd) Synopsis() string { return "Sync SSH keys for the given users." } + +func (s *SyncUsersCmd) Usage() string { + return `sync-user [user1] [user2]` +} + +func (s *SyncUsersCmd) SetFlags(_ *flag.FlagSet) {} + +func (s *SyncUsersCmd) Execute( + _ context.Context, + f *flag.FlagSet, _ ...interface{}, +) subcommands.ExitStatus { + SyncUsers(f.Args()...) + return subcommands.ExitSuccess +} + +type SyncGroupsCmd struct{} + +func (s *SyncGroupsCmd) Name() string { return "sync-group" } + +func (s *SyncGroupsCmd) Synopsis() string { return "Sync SSH keys for the given groups." } + +func (s *SyncGroupsCmd) Usage() string { return `sync-group [group1] [group2]` } + +func (s *SyncGroupsCmd) SetFlags(_ *flag.FlagSet) {} + +func (s *SyncGroupsCmd) Execute( + _ context.Context, + f *flag.FlagSet, _ ...interface{}, +) subcommands.ExitStatus { + SyncGroups(f.Args()...) + return subcommands.ExitSuccess +} + +func main() { + subcommands.Register(subcommands.HelpCommand(), "") + subcommands.Register(subcommands.FlagsCommand(), "") + subcommands.Register(subcommands.CommandsCommand(), "") + subcommands.Register(&FetchCmd{}, "") + subcommands.Register(&SyncUsersCmd{}, "") + subcommands.Register(&SyncGroupsCmd{}, "") + + flag.Parse() + ctx := context.Background() + os.Exit(int(subcommands.Execute(ctx))) +} -- cgit v1.2.3