Memberships: API to add and retrieve user information
             Create user
             Add SSH key
             Ping clients on mutations

Change-Id: I02799b8b4bc88813afeb306cc122a7eaa4496c3c
diff --git a/core/auth/memberships/main.go b/core/auth/memberships/main.go
index 808d5a8..88729a2 100644
--- a/core/auth/memberships/main.go
+++ b/core/auth/memberships/main.go
@@ -12,6 +12,7 @@
 	"net/url"
 	"regexp"
 	"strings"
+	"sync"
 
 	"github.com/ncruces/go-sqlite3"
 	_ "github.com/ncruces/go-sqlite3/driver"
@@ -54,10 +55,17 @@
 	RemoveFromGroupToGroup(parent, child string) error
 	RemoveUserFromTable(username, groupName, tableName string) error
 	GetAllGroups() ([]Group, error)
+	GetAllUsers() ([]User, error)
+	GetUser(username string) (User, error)
+	AddSSHKeyForUser(username, sshKey string) error
+	RemoveSSHKeyForUser(username, sshKey string) error
+	CreateUser(user, email string) error
 }
 
 type Server struct {
-	store Store
+	store         Store
+	syncAddresses map[string]struct{}
+	mu            sync.Mutex
 }
 
 type Group struct {
@@ -65,6 +73,12 @@
 	Description string
 }
 
+type User struct {
+	Username      string   `json:"username"`
+	Email         string   `json:"email"`
+	SSHPublicKeys []string `json:"sshPublicKeys,omitempty"`
+}
+
 type SQLiteStore struct {
 	db *sql.DB
 }
@@ -105,6 +119,17 @@
 			group_name TEXT,
 			FOREIGN KEY(group_name) REFERENCES groups(name),
 			UNIQUE (username, group_name)
+		);
+		CREATE TABLE IF NOT EXISTS users (
+			username TEXT PRIMARY KEY,
+			email TEXT,
+			UNIQUE (email)
+		);
+		CREATE TABLE IF NOT EXISTS user_ssh_keys (
+			username TEXT,
+			ssh_key TEXT,
+			UNIQUE (ssh_key),
+			FOREIGN KEY(username) REFERENCES users(username)
 		);`)
 	if err != nil {
 		return nil, err
@@ -513,13 +538,110 @@
 	return s.queryGroups(query)
 }
 
+func (s *SQLiteStore) AddSSHKeyForUser(username, sshKey string) error {
+	_, err := s.db.Exec(`INSERT INTO user_ssh_keys (username, ssh_key) VALUES (?, ?)`, username, sshKey)
+	if err != nil {
+		sqliteErr, ok := err.(*sqlite3.Error)
+		if ok && sqliteErr.ExtendedCode() == ErrorUniqueConstraintViolation {
+			return fmt.Errorf("%s such SSH public key already exists", sshKey)
+		}
+		return err
+	}
+	return nil
+}
+
+func (s *SQLiteStore) RemoveSSHKeyForUser(username, sshKey string) error {
+	_, err := s.db.Exec(`DELETE FROM user_ssh_keys WHERE username = ? AND ssh_key = ?`, username, sshKey)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *SQLiteStore) GetAllUsers() ([]User, error) {
+	rows, err := s.db.Query(`
+		SELECT users.username, users.email, GROUP_CONCAT(user_ssh_keys.ssh_key, ',')
+		FROM users
+		LEFT JOIN user_ssh_keys ON users.username = user_ssh_keys.username
+		GROUP BY users.username
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var userInfos []User
+	for rows.Next() {
+		var username, email string
+		var sshKeys sql.NullString
+		if err := rows.Scan(&username, &email, &sshKeys); err != nil {
+			return nil, err
+		}
+		user := User{
+			Username: username,
+			Email:    email,
+		}
+		if sshKeys.Valid {
+			user.SSHPublicKeys = strings.Split(sshKeys.String, ",")
+		}
+		userInfos = append(userInfos, user)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return userInfos, nil
+}
+
+func (s *SQLiteStore) GetUser(username string) (User, error) {
+	var user User
+	user.Username = username
+	query := `
+		SELECT users.email, GROUP_CONCAT(user_ssh_keys.ssh_key, ',')
+		FROM users
+		LEFT JOIN user_ssh_keys ON users.username = user_ssh_keys.username
+		WHERE users.username = ?
+		GROUP BY users.username
+	`
+	row := s.db.QueryRow(query, username)
+	var sshKeys sql.NullString
+	err := row.Scan(&user.Email, &sshKeys)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return User{}, fmt.Errorf("no user found with username %s", username)
+		}
+		return User{}, err
+	}
+	if sshKeys.Valid {
+		user.SSHPublicKeys = strings.Split(sshKeys.String, ",")
+	}
+	return user, nil
+}
+
+func (s *SQLiteStore) CreateUser(user, email string) error {
+	_, err := s.db.Exec(`INSERT INTO users (username, email) VALUES (?, ?)`, user, email)
+	if err != nil {
+		sqliteErr, ok := err.(*sqlite3.Error)
+		if ok {
+			if sqliteErr.ExtendedCode() == ErrorUniqueConstraintViolation {
+				if strings.Contains(err.Error(), "UNIQUE constraint failed: users.username") {
+					return fmt.Errorf("username %s already exists", user)
+				}
+				if strings.Contains(err.Error(), "UNIQUE constraint failed: users.email") {
+					return fmt.Errorf("email %s already exists", email)
+				}
+			}
+		}
+		return err
+	}
+	return nil
+}
+
 func getLoggedInUser(r *http.Request) (string, error) {
 	if user := r.Header.Get("X-User"); user != "" {
 		return user, nil
 	} else {
 		return "", fmt.Errorf("unauthenticated")
 	}
-	// return "user", nil
+	// return "tabo", nil
 }
 
 type Status int
@@ -534,15 +656,17 @@
 	go func() {
 		r := mux.NewRouter()
 		r.PathPrefix("/static/").Handler(http.FileServer(http.FS(staticResources)))
-		r.HandleFunc("/group/{group-name}/add-user/", s.addUserToGroupHandler)
-		r.HandleFunc("/group/{parent-group}/add-child-group", s.addChildGroupHandler)
-		r.HandleFunc("/group/{owned-group}/add-owner-group", s.addOwnerGroupHandler)
-		r.HandleFunc("/group/{parent-group}/remove-child-group/{child-group}", s.removeChildGroupHandler)
-		r.HandleFunc("/group/{group-name}/remove-owner/{username}", s.removeOwnerFromGroupHandler)
-		r.HandleFunc("/group/{group-name}/remove-member/{username}", s.removeMemberFromGroupHandler)
+		r.HandleFunc("/group/{group-name}/add-user/", s.addUserToGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{parent-group}/add-child-group", s.addChildGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{owned-group}/add-owner-group", s.addOwnerGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{parent-group}/remove-child-group/{child-group}", s.removeChildGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{group-name}/remove-owner/{username}", s.removeOwnerFromGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{group-name}/remove-member/{username}", s.removeMemberFromGroupHandler).Methods(http.MethodPost)
 		r.HandleFunc("/group/{group-name}", s.groupHandler)
+		r.HandleFunc("/user/{username}/ssh-key", s.addSSHKeyForUserHandler).Methods(http.MethodPost)
+		r.HandleFunc("/user/{username}/remove-ssh-key", s.removeSSHKeyForUserHandler).Methods(http.MethodPost)
 		r.HandleFunc("/user/{username}", s.userHandler)
-		r.HandleFunc("/create-group", s.createGroupHandler)
+		r.HandleFunc("/create-group", s.createGroupHandler).Methods(http.MethodPost)
 		r.HandleFunc("/", s.homePageHandler)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", *port), r)
 	}()
@@ -550,6 +674,8 @@
 		r := mux.NewRouter()
 		r.HandleFunc("/api/init", s.apiInitHandler)
 		r.HandleFunc("/api/user/{username}", s.apiMemberOfHandler)
+		r.HandleFunc("/api/users", s.apiGetAllUsers).Methods(http.MethodGet)
+		r.HandleFunc("/api/users", s.apiCreateUser).Methods(http.MethodPost)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", *apiPort), r)
 	}()
 	return <-e
@@ -616,6 +742,17 @@
 	http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther)
 }
 
+type UserPageData struct {
+	OwnerGroups      []Group
+	MembershipGroups []Group
+	TransitiveGroups []Group
+	LoggedInUserPage bool
+	CurrentUser      string
+	SSHPublicKeys    []string
+	Email            string
+	ErrorMessage     string
+}
+
 func (s *Server) userHandler(w http.ResponseWriter, r *http.Request) {
 	loggedInUser, err := getLoggedInUser(r)
 	if err != nil {
@@ -642,19 +779,19 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	data := struct {
-		OwnerGroups      []Group
-		MembershipGroups []Group
-		TransitiveGroups []Group
-		LoggedInUserPage bool
-		CurrentUser      string
-		ErrorMessage     string
-	}{
+	userInfo, err := s.store.GetUser(user)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	data := UserPageData{
 		OwnerGroups:      ownerGroups,
 		MembershipGroups: membershipGroups,
 		TransitiveGroups: transitiveGroups,
 		LoggedInUserPage: loggedInUserPage,
 		CurrentUser:      user,
+		SSHPublicKeys:    userInfo.SSHPublicKeys,
+		Email:            userInfo.Email,
 		ErrorMessage:     errorMsg,
 	}
 	templates, err := parseTemplates(tmpls)
@@ -674,10 +811,6 @@
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
 		return
 	}
-	if r.Method != http.MethodPost {
-		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
-		return
-	}
 	if err := r.ParseForm(); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -700,6 +833,18 @@
 	http.Redirect(w, r, "/", http.StatusSeeOther)
 }
 
+type GroupPageData struct {
+	GroupName        string
+	Description      string
+	Owners           []string
+	Members          []string
+	AllGroups        []Group
+	TransitiveGroups []Group
+	ChildGroups      []Group
+	OwnerGroups      []Group
+	ErrorMessage     string
+}
+
 func (s *Server) groupHandler(w http.ResponseWriter, r *http.Request) {
 	_, err := getLoggedInUser(r)
 	if err != nil {
@@ -758,17 +903,7 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	data := struct {
-		GroupName        string
-		Description      string
-		Owners           []string
-		Members          []string
-		AllGroups        []Group
-		TransitiveGroups []Group
-		ChildGroups      []Group
-		OwnerGroups      []Group
-		ErrorMessage     string
-	}{
+	data := GroupPageData{
 		GroupName:        groupName,
 		Description:      description,
 		Owners:           owners,
@@ -796,31 +931,29 @@
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
 		return
 	}
-	if r.Method == http.MethodPost {
-		vars := mux.Vars(r)
-		parentGroup := vars["parent-group"]
-		childGroup := vars["child-group"]
-		if err := isValidGroupName(parentGroup); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := isValidGroupName(childGroup); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := s.checkIsOwner(w, loggedInUser, parentGroup); err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusSeeOther)
-			return
-		}
-		err := s.store.RemoveFromGroupToGroup(parentGroup, childGroup)
-		if err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusFound)
-			return
-		}
-		http.Redirect(w, r, "/group/"+parentGroup, http.StatusSeeOther)
+	vars := mux.Vars(r)
+	parentGroup := vars["parent-group"]
+	childGroup := vars["child-group"]
+	if err := isValidGroupName(parentGroup); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
 	}
+	if err := isValidGroupName(childGroup); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	if err := s.checkIsOwner(w, loggedInUser, parentGroup); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
+		return
+	}
+	err = s.store.RemoveFromGroupToGroup(parentGroup, childGroup)
+	if err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/group/"+parentGroup, http.StatusSeeOther)
 }
 
 func (s *Server) removeOwnerFromGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -829,28 +962,26 @@
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
 		return
 	}
-	if r.Method == http.MethodPost {
-		vars := mux.Vars(r)
-		username := vars["username"]
-		groupName := vars["group-name"]
-		tableName := "owners"
-		if err := isValidGroupName(groupName); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusSeeOther)
-			return
-		}
-		err := s.store.RemoveUserFromTable(username, groupName, tableName)
-		if err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusFound)
-			return
-		}
-		http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
+	vars := mux.Vars(r)
+	username := vars["username"]
+	groupName := vars["group-name"]
+	tableName := "owners"
+	if err := isValidGroupName(groupName); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
 	}
+	if err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
+		return
+	}
+	err = s.store.RemoveUserFromTable(username, groupName, tableName)
+	if err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
 }
 
 func (s *Server) removeMemberFromGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -859,35 +990,29 @@
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
 		return
 	}
-	if r.Method == http.MethodPost {
-		vars := mux.Vars(r)
-		username := vars["username"]
-		groupName := vars["group-name"]
-		tableName := "user_to_group"
-		if err := isValidGroupName(groupName); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusSeeOther)
-			return
-		}
-		err := s.store.RemoveUserFromTable(username, groupName, tableName)
-		if err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusFound)
-			return
-		}
-		http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
+	vars := mux.Vars(r)
+	username := vars["username"]
+	groupName := vars["group-name"]
+	tableName := "user_to_group"
+	if err := isValidGroupName(groupName); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
 	}
+	if err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
+		return
+	}
+	err = s.store.RemoveUserFromTable(username, groupName, tableName)
+	if err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
 }
 
 func (s *Server) addUserToGroupHandler(w http.ResponseWriter, r *http.Request) {
-	if r.Method != http.MethodPost {
-		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
-		return
-	}
 	loggedInUser, err := getLoggedInUser(r)
 	if err != nil {
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
@@ -933,10 +1058,6 @@
 
 func (s *Server) addChildGroupHandler(w http.ResponseWriter, r *http.Request) {
 	// TODO(dtabidze): In future we might need to make one group OWNER of another and not just a member.
-	if r.Method != http.MethodPost {
-		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
-		return
-	}
 	loggedInUser, err := getLoggedInUser(r)
 	if err != nil {
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
@@ -967,10 +1088,6 @@
 }
 
 func (s *Server) addOwnerGroupHandler(w http.ResponseWriter, r *http.Request) {
-	if r.Method != http.MethodPost {
-		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
-		return
-	}
 	loggedInUser, err := getLoggedInUser(r)
 	if err != nil {
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
@@ -1000,6 +1117,62 @@
 	http.Redirect(w, r, "/group/"+ownedGroup, http.StatusSeeOther)
 }
 
+func (s *Server) addSSHKeyForUserHandler(w http.ResponseWriter, r *http.Request) {
+	defer s.pingAllSyncAddresses()
+	loggedInUser, err := getLoggedInUser(r)
+	if err != nil {
+		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
+		return
+	}
+	vars := mux.Vars(r)
+	username := vars["username"]
+	if loggedInUser != username {
+		http.Error(w, "You are not allowed to add SSH key for someone else", http.StatusUnauthorized)
+		return
+	}
+	sshKey := r.FormValue("ssh-key")
+	if sshKey == "" {
+		http.Error(w, "SSH key not present", http.StatusBadRequest)
+		return
+	}
+	if err := s.store.AddSSHKeyForUser(username, sshKey); err != nil {
+		redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther)
+}
+
+func (s *Server) removeSSHKeyForUserHandler(w http.ResponseWriter, r *http.Request) {
+	defer s.pingAllSyncAddresses()
+	loggedInUser, err := getLoggedInUser(r)
+	if err != nil {
+		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
+		return
+	}
+	vars := mux.Vars(r)
+	username := vars["username"]
+	if loggedInUser != username {
+		http.Error(w, "You are not allowed to remove SSH key for someone else", http.StatusUnauthorized)
+		return
+	}
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	sshKey := r.FormValue("ssh-key")
+	if sshKey == "" {
+		http.Error(w, "SSH key not present", http.StatusBadRequest)
+		return
+	}
+	if err := s.store.RemoveSSHKeyForUser(username, sshKey); err != nil {
+		redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther)
+}
+
 type initRequest struct {
 	Owner  string   `json:"owner"`
 	Groups []string `json:"groups"`
@@ -1045,6 +1218,76 @@
 	}
 }
 
+func (s *Server) apiGetAllUsers(w http.ResponseWriter, r *http.Request) {
+	defer s.pingAllSyncAddresses()
+	selfAddress := r.FormValue("selfAddress")
+	if selfAddress != "" {
+		s.addSyncAddress(selfAddress)
+	}
+	users, err := s.store.GetAllUsers()
+	if err != nil {
+		http.Error(w, "Failed to retrieve SSH keys", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(users); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) apiCreateUser(w http.ResponseWriter, r *http.Request) {
+	defer s.pingAllSyncAddresses()
+	selfAddress := r.FormValue("selfAddress")
+	if selfAddress != "" {
+		s.addSyncAddress(selfAddress)
+	}
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	username := r.FormValue("username")
+	email := r.FormValue("email")
+	if username == "" {
+		http.Error(w, "Username cannot be empty", http.StatusBadRequest)
+		return
+	}
+	if email == "" {
+		http.Error(w, "Email cannot be empty", http.StatusBadRequest)
+		return
+	}
+	username = strings.ToLower(username)
+	email = strings.ToLower(email)
+	err := s.store.CreateUser(username, email)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+func (s *Server) pingAllSyncAddresses() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	for address := range s.syncAddresses {
+		resp, err := http.Get(address)
+		if err != nil {
+			log.Printf("Failed to ping %s: %v", address, err)
+			continue
+		}
+		resp.Body.Close()
+		if resp.StatusCode != http.StatusOK {
+			log.Printf("Ping to %s returned status %d", address, resp.StatusCode)
+		}
+	}
+}
+
+func (s *Server) addSyncAddress(address string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.syncAddresses[address] = struct{}{}
+}
+
 func convertStatus(status string) (Status, error) {
 	switch status {
 	case "Owner":
@@ -1077,6 +1320,10 @@
 	if err != nil {
 		panic(err)
 	}
-	s := Server{store}
+	s := Server{
+		store:         store,
+		syncAddresses: make(map[string]struct{}),
+		mu:            sync.Mutex{},
+	}
 	log.Fatal(s.Start())
 }