github.com/keybase/client/go@v0.0.0-20241007131713-f10651d043c8/kbnm/handler.go (about) 1 package main 2 3 import ( 4 "bufio" 5 "bytes" 6 "errors" 7 "fmt" 8 "io" 9 "os" 10 "os/exec" 11 "regexp" 12 "strings" 13 ) 14 15 var errInvalidMethod = errors.New("invalid method") 16 17 var errInvalidInput = errors.New("invalid input") 18 19 var errMissingField = errors.New("missing field") 20 21 var errUserNotFound = errors.New("user not found") 22 23 var errKeybaseNotRunning = errors.New("keybase is not running") 24 25 var errKeybaseNotLoggedIn = errors.New("keybase is not logged in") 26 27 type errUnexpected struct { 28 value string 29 } 30 31 func (err *errUnexpected) Error() string { 32 return fmt.Sprintf("unexpected error: %s", err.value) 33 } 34 35 func execRunner(cmd *exec.Cmd) error { 36 return cmd.Run() 37 } 38 39 // reUsernameQuery matches valid username queries 40 var reUsernameQuery = regexp.MustCompile(`^[a-zA-Z0-9_\-.:@]{1,256}$`) 41 42 // checkUsernameQuery returns the query if it's valid to use 43 // We return the valid string so that it can be used as a separate 44 // pre-validated variable which makes it less likely that the validation will 45 // be accidentally removed in the future, as opposed to if we just continued 46 // using the input variable after validating it. 47 func checkUsernameQuery(s string) (string, error) { 48 if s == "" { 49 return "", errMissingField 50 } 51 if !reUsernameQuery.MatchString(s) { 52 return "", errInvalidInput 53 } 54 return s, nil 55 } 56 57 // newHandler returns a request handler. 58 func newHandler() *handler { 59 return &handler{ 60 Run: execRunner, 61 FindKeybaseBinary: func() (string, error) { 62 return findKeybaseBinary(keybaseBinary) 63 }, 64 } 65 } 66 67 type handler struct { 68 // Run wraps the equivalent of cmd.Run(), allowing for mocking 69 Run func(cmd *exec.Cmd) error 70 // FindCmd returns the path of the keybase binary if it can find it 71 FindKeybaseBinary func() (string, error) 72 } 73 74 // Handle accepts a request, handles it, and returns an optional result if there was no error 75 func (h *handler) Handle(req *Request) (interface{}, error) { 76 switch req.Method { 77 case "chat": 78 return nil, h.handleChat(req) 79 case "query": 80 return h.handleQuery(req) 81 } 82 return nil, errInvalidMethod 83 } 84 85 // handleChat sends a chat message to a user. 86 func (h *handler) handleChat(req *Request) error { 87 if req.Body == "" { 88 return errMissingField 89 } 90 idQuery, err := checkUsernameQuery(req.To) 91 if err != nil { 92 return err 93 } 94 95 binPath, err := h.FindKeybaseBinary() 96 if err != nil { 97 return err 98 } 99 100 var out bytes.Buffer 101 cmd := exec.Command(binPath, "chat", "send", "--private", idQuery) 102 cmd.Env = append(os.Environ(), "KEYBASE_LOG_FORMAT=plain") 103 cmd.Stdin = strings.NewReader(req.Body) 104 cmd.Stdout = &out 105 cmd.Stderr = &out 106 107 if err := h.Run(cmd); err != nil { 108 return parseError(&out, err) 109 } 110 111 return nil 112 } 113 114 type resultQuery struct { 115 Username string `json:"username"` 116 } 117 118 // parseQuery reads the stderr from a keybase query command and returns a result 119 func parseQuery(r io.Reader) (*resultQuery, error) { 120 scanner := bufio.NewScanner(r) 121 122 var lastErrLine string 123 for scanner.Scan() { 124 // Find a line that looks like... "[INFO] 001 Identifying someuser" 125 line := strings.TrimSpace(scanner.Text()) 126 parts := strings.Split(line, " ") 127 if len(parts) < 4 { 128 continue 129 } 130 131 // Short circuit errors 132 if parts[0] == "[ERRO]" { 133 lastErrLine = strings.Join(parts[2:], " ") 134 if lastErrLine == "Not found" { 135 return nil, errUserNotFound 136 } 137 continue 138 } 139 140 if parts[2] != "Identifying" { 141 continue 142 } 143 144 resp := &resultQuery{ 145 Username: parts[3], 146 } 147 return resp, nil 148 } 149 150 if err := scanner.Err(); err != nil { 151 return nil, scanner.Err() 152 } 153 154 // This could happen if the keybase service is broken 155 return nil, &errUnexpected{lastErrLine} 156 } 157 158 // parseError reads stderr output and returns an error made from it. If it 159 // fails to parse an error, it returns the fallback error. 160 func parseError(r io.Reader, fallback error) error { 161 scanner := bufio.NewScanner(r) 162 163 // Find the final error 164 var lastErr error 165 for scanner.Scan() { 166 // Should be of the form "[ERRO] 001 Not found" or "...: No resolution found" 167 line := strings.TrimSpace(scanner.Text()) 168 if line == "" { 169 continue 170 } 171 // Check some error states we know about: 172 if strings.Contains(line, "Keybase isn't running.") { 173 return errKeybaseNotRunning 174 } 175 if strings.Contains(line, "You are not logged into Keybase.") { 176 return errKeybaseNotLoggedIn 177 } 178 parts := strings.SplitN(line, " ", 3) 179 if len(parts) < 3 { 180 continue 181 } 182 if parts[0] != "[ERRO]" { 183 continue 184 } 185 if strings.HasSuffix(parts[2], "No resolution found") { 186 return errUserNotFound 187 } 188 if strings.HasPrefix(parts[2], "Not found") { 189 return errUserNotFound 190 } 191 lastErr = fmt.Errorf(parts[2]) 192 } 193 194 if lastErr != nil { 195 return lastErr 196 } 197 198 return fallback 199 } 200 201 // handleQuery searches whether a user is present in Keybase. 202 func (h *handler) handleQuery(req *Request) (*resultQuery, error) { 203 idQuery, err := checkUsernameQuery(req.To) 204 if err != nil { 205 return nil, err 206 } 207 208 binPath, err := h.FindKeybaseBinary() 209 if err != nil { 210 return nil, err 211 } 212 213 // Unfortunately `keybase id ...` does not support JSON output, so we parse the output 214 var out bytes.Buffer 215 cmd := exec.Command(binPath, "id", idQuery) 216 cmd.Env = append(os.Environ(), "KEYBASE_LOG_FORMAT=plain") 217 cmd.Stdout = &out 218 cmd.Stderr = &out 219 220 if err := h.Run(cmd); err != nil { 221 return nil, parseError(&out, err) 222 } 223 224 return parseQuery(&out) 225 }