| // Copyright 2015 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // Code related to remote buildlets. See x/build/remote-buildlet.txt |
| |
| package main // import "golang.org/x/build/cmd/coordinator" |
| |
| import ( |
| "bufio" |
| "bytes" |
| "context" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "html" |
| "io" |
| "io/ioutil" |
| "log" |
| "net" |
| "net/http" |
| "net/http/httputil" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "sort" |
| "strconv" |
| "strings" |
| "sync" |
| "syscall" |
| "time" |
| "unsafe" |
| |
| "cloud.google.com/go/compute/metadata" |
| |
| "github.com/gliderlabs/ssh" |
| "github.com/kr/pty" |
| "golang.org/x/build/buildlet" |
| "golang.org/x/build/dashboard" |
| "golang.org/x/build/internal/gophers" |
| gossh "golang.org/x/crypto/ssh" |
| ) |
| |
| var ( |
| remoteBuildlets = struct { |
| sync.Mutex |
| m map[string]*remoteBuildlet // keyed by buildletName |
| }{m: map[string]*remoteBuildlet{}} |
| |
| cleanTimer *time.Timer |
| ) |
| |
| const ( |
| remoteBuildletIdleTimeout = 30 * time.Minute |
| remoteBuildletCleanInterval = time.Minute |
| ) |
| |
| func init() { |
| cleanTimer = time.AfterFunc(remoteBuildletCleanInterval, expireBuildlets) |
| } |
| |
| type remoteBuildlet struct { |
| User string // "user-foo" build key |
| Name string // dup of key |
| HostType string |
| BuilderType string // default builder config to use if not overwritten |
| Created time.Time |
| Expires time.Time |
| |
| buildlet *buildlet.Client |
| } |
| |
| // renew renews rb's idle timeout if ctx hasn't expired. |
| // renew should run in its own goroutine. |
| func (rb *remoteBuildlet) renew(ctx context.Context) { |
| remoteBuildlets.Lock() |
| defer remoteBuildlets.Unlock() |
| select { |
| case <-ctx.Done(): |
| return |
| default: |
| } |
| if got := remoteBuildlets.m[rb.Name]; got == rb { |
| rb.Expires = time.Now().Add(remoteBuildletIdleTimeout) |
| time.AfterFunc(time.Minute, func() { rb.renew(ctx) }) |
| } |
| } |
| |
| func addRemoteBuildlet(rb *remoteBuildlet) (name string) { |
| remoteBuildlets.Lock() |
| defer remoteBuildlets.Unlock() |
| n := 0 |
| for { |
| name = fmt.Sprintf("%s-%s-%d", rb.User, rb.BuilderType, n) |
| if _, ok := remoteBuildlets.m[name]; ok { |
| n++ |
| } else { |
| remoteBuildlets.m[name] = rb |
| return name |
| } |
| } |
| } |
| |
| func expireBuildlets() { |
| defer cleanTimer.Reset(remoteBuildletCleanInterval) |
| remoteBuildlets.Lock() |
| defer remoteBuildlets.Unlock() |
| now := time.Now() |
| for name, rb := range remoteBuildlets.m { |
| if !rb.Expires.IsZero() && rb.Expires.Before(now) { |
| go rb.buildlet.Close() |
| delete(remoteBuildlets.m, name) |
| } |
| } |
| } |
| |
| // always wrapped in requireBuildletProxyAuth. |
| func handleBuildletCreate(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "POST" { |
| http.Error(w, "POST required", 400) |
| return |
| } |
| const serverVersion = "20160922" // sent by cmd/gomote via buildlet/remote.go |
| if version := r.FormValue("version"); version < serverVersion { |
| http.Error(w, fmt.Sprintf("gomote client version %q is too old; predates server version %q", version, serverVersion), 400) |
| return |
| } |
| builderType := r.FormValue("builderType") |
| if builderType == "" { |
| http.Error(w, "missing 'builderType' parameter", 400) |
| return |
| } |
| bconf, ok := dashboard.Builders[builderType] |
| if !ok { |
| http.Error(w, "unknown builder type in 'builderType' parameter", 400) |
| return |
| } |
| user, _, _ := r.BasicAuth() |
| pool := poolForConf(bconf) |
| |
| var closeNotify <-chan bool |
| if cn, ok := w.(http.CloseNotifier); ok { |
| closeNotify = cn.CloseNotify() |
| } |
| |
| ctx := context.WithValue(context.Background(), buildletTimeoutOpt{}, time.Duration(0)) |
| ctx, cancel := context.WithCancel(ctx) |
| // NOTE: don't defer close this cancel. If the context is |
| // closed, the pod is destroyed. |
| // TODO: clean this up. |
| |
| // Doing a release? |
| if user == "release" || user == "adg" || user == "bradfitz" { |
| ctx = context.WithValue(ctx, highPriorityOpt{}, true) |
| } |
| |
| resc := make(chan *buildlet.Client) |
| errc := make(chan error) |
| go func() { |
| bc, err := pool.GetBuildlet(ctx, bconf.HostType, loggerFunc(func(event string, optText ...string) { |
| var extra string |
| if len(optText) > 0 { |
| extra = " " + optText[0] |
| } |
| log.Printf("creating buildlet %s for %s: %s%s", bconf.HostType, user, event, extra) |
| })) |
| if bc != nil { |
| resc <- bc |
| return |
| } |
| errc <- err |
| }() |
| for { |
| select { |
| case bc := <-resc: |
| rb := &remoteBuildlet{ |
| User: user, |
| BuilderType: builderType, |
| HostType: bconf.HostType, |
| buildlet: bc, |
| Created: time.Now(), |
| Expires: time.Now().Add(remoteBuildletIdleTimeout), |
| } |
| rb.Name = addRemoteBuildlet(rb) |
| jenc, err := json.MarshalIndent(rb, "", " ") |
| if err != nil { |
| http.Error(w, err.Error(), 500) |
| log.Print(err) |
| return |
| } |
| log.Printf("created buildlet %v for %v (%s)", rb.Name, rb.User, bc.String()) |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| jenc = append(jenc, '\n') |
| w.Write(jenc) |
| return |
| case err := <-errc: |
| log.Printf("error creating buildlet: %v", err) |
| http.Error(w, err.Error(), 500) |
| return |
| case <-closeNotify: |
| log.Printf("client went away during buildlet create request") |
| cancel() |
| closeNotify = nil // unnecessary, but habit. |
| } |
| } |
| } |
| |
| // always wrapped in requireBuildletProxyAuth. |
| func handleBuildletList(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "GET" { |
| http.Error(w, "GET required", 400) |
| return |
| } |
| res := make([]*remoteBuildlet, 0) // so it's never JSON "null" |
| remoteBuildlets.Lock() |
| defer remoteBuildlets.Unlock() |
| user, _, _ := r.BasicAuth() |
| for _, rb := range remoteBuildlets.m { |
| if rb.User == user { |
| res = append(res, rb) |
| } |
| } |
| sort.Sort(byBuildletName(res)) |
| jenc, err := json.MarshalIndent(res, "", " ") |
| if err != nil { |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| jenc = append(jenc, '\n') |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| w.Write(jenc) |
| } |
| |
| type byBuildletName []*remoteBuildlet |
| |
| func (s byBuildletName) Len() int { return len(s) } |
| func (s byBuildletName) Less(i, j int) bool { return s[i].Name < s[j].Name } |
| func (s byBuildletName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } |
| |
| func remoteBuildletStatus() string { |
| remoteBuildlets.Lock() |
| defer remoteBuildlets.Unlock() |
| |
| if len(remoteBuildlets.m) == 0 { |
| return "<i>(none)</i>" |
| } |
| |
| var buf bytes.Buffer |
| var all []*remoteBuildlet |
| for _, rb := range remoteBuildlets.m { |
| all = append(all, rb) |
| } |
| sort.Sort(byBuildletName(all)) |
| |
| buf.WriteString("<ul>") |
| for _, rb := range all { |
| fmt.Fprintf(&buf, "<li><b>%s</b>, created %v ago, expires in %v</li>\n", |
| html.EscapeString(rb.Name), |
| time.Since(rb.Created), rb.Expires.Sub(time.Now())) |
| } |
| buf.WriteString("</ul>") |
| |
| return buf.String() |
| } |
| |
| // httpRouter separates out HTTP traffic being proxied |
| // to buildlets on behalf of remote clients from traffic |
| // destined for the coordinator itself (the default). |
| type httpRouter struct{} |
| |
| func (httpRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| if r.Header.Get("X-Buildlet-Proxy") != "" { |
| requireBuildletProxyAuth(http.HandlerFunc(proxyBuildletHTTP)).ServeHTTP(w, r) |
| } else { |
| http.DefaultServeMux.ServeHTTP(w, r) |
| } |
| } |
| |
| func proxyBuildletHTTP(w http.ResponseWriter, r *http.Request) { |
| if r.TLS == nil { |
| http.Error(w, "https required", http.StatusBadRequest) |
| return |
| } |
| buildletName := r.Header.Get("X-Buildlet-Proxy") |
| if buildletName == "" { |
| http.Error(w, "missing X-Buildlet-Proxy; server misconfig", http.StatusInternalServerError) |
| return |
| } |
| remoteBuildlets.Lock() |
| rb, ok := remoteBuildlets.m[buildletName] |
| if ok { |
| rb.Expires = time.Now().Add(remoteBuildletIdleTimeout) |
| } |
| remoteBuildlets.Unlock() |
| if !ok { |
| http.Error(w, "unknown or expired buildlet", http.StatusBadGateway) |
| return |
| } |
| user, _, _ := r.BasicAuth() |
| if rb.User != user { |
| http.Error(w, "you don't own that buildlet", http.StatusUnauthorized) |
| return |
| } |
| |
| if r.Method == "POST" && r.URL.Path == "/halt" { |
| err := rb.buildlet.Close() |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| } |
| rb.buildlet.Close() |
| remoteBuildlets.Lock() |
| delete(remoteBuildlets.m, buildletName) |
| remoteBuildlets.Unlock() |
| return |
| } |
| |
| outReq, err := http.NewRequest(r.Method, rb.buildlet.URL()+r.URL.Path+"?"+r.URL.RawQuery, r.Body) |
| if err != nil { |
| log.Printf("bad proxy request: %v", err) |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| outReq.Header = r.Header |
| outReq.ContentLength = r.ContentLength |
| proxy := &httputil.ReverseProxy{ |
| Director: func(*http.Request) {}, // nothing |
| Transport: rb.buildlet.ProxyRoundTripper(), |
| FlushInterval: 500 * time.Millisecond, |
| } |
| proxy.ServeHTTP(w, outReq) |
| } |
| |
| func requireBuildletProxyAuth(h http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| user, pass, ok := r.BasicAuth() |
| if !ok { |
| http.Error(w, "missing required authentication", 400) |
| return |
| } |
| if !strings.HasPrefix(user, "user-") || builderKey(user) != pass { |
| if *mode == "dev" { |
| log.Printf("ignoring gomote authentication failure for %q in dev mode", user) |
| } else { |
| http.Error(w, "bad username or password", 401) |
| return |
| } |
| } |
| h.ServeHTTP(w, r) |
| }) |
| } |
| |
| var sshPrivateKeyFile string |
| |
| func writeSSHPrivateKeyToTempFile(key []byte) (path string, err error) { |
| tf, err := ioutil.TempFile("", "ssh-priv-key") |
| if err != nil { |
| return "", err |
| } |
| if err := tf.Chmod(0600); err != nil { |
| return "", err |
| } |
| if _, err := tf.Write(key); err != nil { |
| return "", err |
| } |
| return tf.Name(), tf.Close() |
| } |
| |
| func listenAndServeSSH() { |
| const listenAddr = ":2222" // TODO: flag if ever necessary? |
| var hostKey []byte |
| var err error |
| if *mode == "dev" { |
| sshPrivateKeyFile = filepath.Join(os.Getenv("HOME"), "keys", "id_gomotessh_rsa") |
| hostKey, err = ioutil.ReadFile(sshPrivateKeyFile) |
| if os.IsNotExist(err) { |
| log.Printf("SSH host key file %s doesn't exist; not running SSH server.", sshPrivateKeyFile) |
| return |
| } |
| if err != nil { |
| log.Fatal(err) |
| } |
| } else { |
| if storageClient == nil { |
| log.Printf("GCS storage client not available; not running SSH server.") |
| return |
| } |
| r, err := storageClient.Bucket(buildEnv.BuildletBucket).Object("coordinator-gomote-ssh.key").NewReader(context.Background()) |
| if err != nil { |
| log.Printf("Failed to read ssh host key: %v; not running SSH server.", err) |
| return |
| } |
| hostKey, err = ioutil.ReadAll(r) |
| if err != nil { |
| log.Printf("Failed to read ssh host key: %v; not running SSH server.", err) |
| return |
| } |
| sshPrivateKeyFile, err = writeSSHPrivateKeyToTempFile(hostKey) |
| log.Printf("ssh: writeSSHPrivateKeyToTempFile = %v, %v", sshPrivateKeyFile, err) |
| if err != nil { |
| log.Printf("error writing ssh private key to temp file: %v; not running SSH server", err) |
| return |
| } |
| } |
| signer, err := gossh.ParsePrivateKey(hostKey) |
| if err != nil { |
| log.Printf("failed to parse SSH host key: %v; running running SSH server", err) |
| return |
| } |
| |
| s := &ssh.Server{ |
| Addr: listenAddr, |
| Handler: handleIncomingSSHPostAuth, |
| PublicKeyHandler: handleSSHPublicKeyAuth, |
| } |
| s.AddHostKey(signer) |
| |
| log.Printf("running SSH server on %s", listenAddr) |
| err = s.ListenAndServe() |
| log.Printf("SSH server ended with error: %v", err) |
| // TODO: make ListenAndServe errors Fatal, once it has a proven track record. starting paranoid. |
| } |
| |
| func handleSSHPublicKeyAuth(ctx ssh.Context, key ssh.PublicKey) bool { |
| inst := ctx.User() // expected to be of form "user-USER-goos-goarch-etc" |
| user := userFromGomoteInstanceName(inst) |
| if user == "" { |
| return false |
| } |
| // Map the gomote username to the github username, and use the |
| // github user's public ssh keys for authentication. This is |
| // mostly of laziness and pragmatism, not wanting to invent or |
| // maintain a new auth mechanism or password/key registry. |
| githubUser := gophers.GithubOfGomoteUser(user) |
| keys := githubPublicKeys(githubUser) |
| for _, authKey := range keys { |
| if ssh.KeysEqual(key, authKey.PublicKey) { |
| log.Printf("for instance %q, github user %q key matched: %s", inst, githubUser, authKey.AuthorizedLine) |
| return true |
| } |
| } |
| return false |
| } |
| |
| func handleIncomingSSHPostAuth(s ssh.Session) { |
| inst := s.User() |
| user := userFromGomoteInstanceName(inst) |
| |
| requestedMutable := strings.HasPrefix(inst, "mutable-") |
| if requestedMutable { |
| inst = strings.TrimPrefix(inst, "mutable-") |
| } |
| |
| ptyReq, winCh, isPty := s.Pty() |
| if !isPty { |
| fmt.Fprintf(s, "scp etc not yet supported; https://golang.org/issue/21140\n") |
| return |
| } |
| |
| pubKey, err := metadata.ProjectAttributeValue("gomote-ssh-public-key") |
| if err != nil || pubKey == "" { |
| if err == nil { |
| err = errors.New("not found") |
| } |
| fmt.Fprintf(s, "failed to get GCE gomote-ssh-public-key: %v\n", err) |
| return |
| } |
| |
| remoteBuildlets.Lock() |
| rb, ok := remoteBuildlets.m[inst] |
| remoteBuildlets.Unlock() |
| if !ok { |
| fmt.Fprintf(s, "unknown instance %q", inst) |
| return |
| } |
| |
| hostType := rb.HostType |
| hostConf, ok := dashboard.Hosts[hostType] |
| if !ok { |
| fmt.Fprintf(s, "instance %q has unknown host type %q\n", inst, hostType) |
| return |
| } |
| |
| bconf, ok := dashboard.Builders[rb.BuilderType] |
| if !ok { |
| fmt.Fprintf(s, "instance %q has unknown builder type %q\n", inst, rb.BuilderType) |
| return |
| } |
| |
| ctx, cancel := context.WithCancel(s.Context()) |
| defer cancel() |
| go rb.renew(ctx) |
| |
| sshUser := hostConf.SSHUsername |
| useLocalSSHProxy := bconf.GOOS() != "plan9" |
| if sshUser == "" && useLocalSSHProxy { |
| fmt.Fprintf(s, "instance %q host type %q does not have SSH configured\n", inst, hostType) |
| return |
| } |
| if !hostConf.IsHermetic() && !requestedMutable { |
| fmt.Fprintf(s, "WARNING: instance %q host type %q is not currently\n", inst, hostType) |
| fmt.Fprintf(s, "configured to have a hermetic filesystem per boot.\n") |
| fmt.Fprintf(s, "You must be careful not to modify machine state\n") |
| fmt.Fprintf(s, "that will affect future builds. Do you agree? If so,\n") |
| fmt.Fprintf(s, "run gomote ssh --i-will-not-break-the-host <INST>\n") |
| return |
| } |
| |
| log.Printf("connecting to ssh to instance %q ...", inst) |
| |
| fmt.Fprintf(s, "# Welcome to the gomote ssh proxy, %s.\n", user) |
| fmt.Fprintf(s, "# Connecting to/starting remote ssh...\n") |
| fmt.Fprintf(s, "#\n") |
| |
| var localProxyPort int |
| if useLocalSSHProxy { |
| sshConn, err := rb.buildlet.ConnectSSH(sshUser, pubKey) |
| log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err) |
| if err != nil { |
| fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err) |
| return |
| } |
| defer sshConn.Close() |
| |
| // Now listen on some localhost port that we'll proxy to sshConn. |
| // The openssh ssh command line tool will connect to this IP. |
| ln, err := net.Listen("tcp", "localhost:0") |
| if err != nil { |
| fmt.Fprintf(s, "local listen error: %v\n", err) |
| return |
| } |
| localProxyPort = ln.Addr().(*net.TCPAddr).Port |
| log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort) |
| var lnCloseOnce sync.Once |
| lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) } |
| defer lnClose() |
| |
| // Accept at most one connection from localProxyPort and proxy |
| // it to sshConn. |
| go func() { |
| c, err := ln.Accept() |
| lnClose() |
| if err != nil { |
| return |
| } |
| defer c.Close() |
| errc := make(chan error, 1) |
| go func() { |
| _, err := io.Copy(c, sshConn) |
| errc <- err |
| }() |
| go func() { |
| _, err := io.Copy(sshConn, c) |
| errc <- err |
| }() |
| err = <-errc |
| }() |
| } |
| workDir, err := rb.buildlet.WorkDir() |
| if err != nil { |
| fmt.Fprintf(s, "Error getting WorkDir: %v\n", err) |
| return |
| } |
| ip, _, ipErr := net.SplitHostPort(rb.buildlet.IPPort()) |
| |
| fmt.Fprintf(s, "# `gomote push` and the builders use:\n") |
| fmt.Fprintf(s, "# - workdir: %s\n", workDir) |
| fmt.Fprintf(s, "# - GOROOT: %s/go\n", workDir) |
| fmt.Fprintf(s, "# - GOPATH: %s/gopath\n", workDir) |
| fmt.Fprintf(s, "# - env: %s\n", strings.Join(bconf.Env(), " ")) // TODO: shell quote? |
| fmt.Fprintf(s, "# Happy debugging.\n") |
| |
| log.Printf("ssh to %s: starting ssh -p %d for %s@localhost", inst, localProxyPort, sshUser) |
| var cmd *exec.Cmd |
| switch bconf.GOOS() { |
| default: |
| cmd = exec.Command("ssh", |
| "-p", strconv.Itoa(localProxyPort), |
| "-o", "UserKnownHostsFile=/dev/null", |
| "-o", "StrictHostKeyChecking=no", |
| "-i", sshPrivateKeyFile, |
| sshUser+"@localhost") |
| case "plan9": |
| fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n") |
| if ipErr != nil { |
| fmt.Fprintf(s, "# Failed to get IP out of %q: %v\n", rb.buildlet.IPPort(), err) |
| return |
| } |
| cmd = exec.Command("/usr/local/bin/drawterm", |
| "-a", ip, "-c", ip, "-u", "glenda", "-k", "user=glenda") |
| } |
| cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term)) |
| f, err := pty.Start(cmd) |
| if err != nil { |
| log.Printf("running ssh client to %s: %v", inst, err) |
| return |
| } |
| defer f.Close() |
| go func() { |
| for win := range winCh { |
| setWinsize(f, win.Width, win.Height) |
| } |
| }() |
| go func() { |
| io.Copy(f, s) // stdin |
| }() |
| io.Copy(s, f) // stdout |
| cmd.Process.Kill() |
| cmd.Wait() |
| } |
| |
| func setWinsize(f *os.File, w, h int) { |
| syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ), |
| uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0}))) |
| } |
| |
| // userFromGomoteInstanceName returns the username part of a gomote |
| // remote instance name. |
| // |
| // The instance name is of two forms. The normal form is: |
| // |
| // user-bradfitz-linux-amd64-0 |
| // |
| // The overloaded form to convey that the user accepts responsibility |
| // for changes to the underlying host is to prefix the same instance |
| // name with the string "mutable-", such as: |
| // |
| // mutable-user-bradfitz-darwin-amd64-10_8-0 |
| // |
| // The mutable part is ignored by this function. |
| func userFromGomoteInstanceName(name string) string { |
| name = strings.TrimPrefix(name, "mutable-") |
| if !strings.HasPrefix(name, "user-") { |
| return "" |
| } |
| user := name[len("user-"):] |
| hyphen := strings.IndexByte(user, '-') |
| if hyphen == -1 { |
| return "" |
| } |
| return user[:hyphen] |
| } |
| |
| // authorizedKey is a Github user's SSH authorized key, in both string and parsed format. |
| type authorizedKey struct { |
| AuthorizedLine string // e.g. "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILj8HGIG9NsT34PHxO8IBq0riSBv7snp30JM8AanBGoV" |
| PublicKey ssh.PublicKey |
| } |
| |
| func githubPublicKeys(user string) []authorizedKey { |
| // TODO: caching, rate limiting. |
| req, err := http.NewRequest("GET", "https://github.com/"+user+".keys", nil) |
| if err != nil { |
| return nil |
| } |
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| defer cancel() |
| req = req.WithContext(ctx) |
| res, err := http.DefaultClient.Do(req) |
| if err != nil { |
| log.Printf("getting %s github keys: %v", user, err) |
| return nil |
| } |
| defer res.Body.Close() |
| if res.StatusCode != 200 { |
| return nil |
| } |
| var keys []authorizedKey |
| bs := bufio.NewScanner(res.Body) |
| for bs.Scan() { |
| key, _, _, _, err := ssh.ParseAuthorizedKey(bs.Bytes()) |
| if err != nil { |
| log.Printf("parsing github user %q key %q: %v", user, bs.Text(), err) |
| continue |
| } |
| keys = append(keys, authorizedKey{ |
| PublicKey: key, |
| AuthorizedLine: strings.TrimSpace(bs.Text()), |
| }) |
| } |
| if err := bs.Err(); err != nil { |
| return nil |
| } |
| return keys |
| } |