aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md26
-rw-r--r--github.go281
-rw-r--r--go.mod5
-rw-r--r--main.go80
4 files changed, 392 insertions, 0 deletions
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 <flags> <subcommand> <subcommand args>
+
+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)))
+}