| // Copyright 2014 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. |
| |
| // The buildlet is an HTTP server that untars content to disk and runs |
| // commands it has untarred, streaming their output back over HTTP. |
| // It is part of Go's continuous build system. |
| // |
| // This program intentionally allows remote code execution, and |
| // provides no security of its own. It is assumed that any user uses |
| // it with an appropriately-configured firewall between their VM |
| // instances. |
| package main // import "golang.org/x/build/cmd/buildlet" |
| |
| import ( |
| "archive/tar" |
| "bytes" |
| "compress/gzip" |
| "context" |
| "crypto/sha1" |
| "crypto/tls" |
| "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "log" |
| "net" |
| "net/http" |
| "net/url" |
| "os" |
| "os/exec" |
| "path" |
| "path/filepath" |
| "regexp" |
| "runtime" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
| |
| "cloud.google.com/go/compute/metadata" |
| "github.com/aws/aws-sdk-go/aws/ec2metadata" |
| "github.com/aws/aws-sdk-go/aws/session" |
| "golang.org/x/build/buildlet" |
| "golang.org/x/build/internal/cloud" |
| "golang.org/x/build/pargzip" |
| ) |
| |
| var ( |
| haltEntireOS = flag.Bool("halt", true, "halt OS in /halt handler. If false, the buildlet process just ends.") |
| rebootOnHalt = flag.Bool("reboot", false, "reboot system in /halt handler.") |
| workDir = flag.String("workdir", "", "Temporary directory to use. The contents of this directory may be deleted at any time. If empty, TempDir is used to create one.") |
| listenAddr = flag.String("listen", "AUTO", "address to listen on. Unused in reverse mode. Warning: this service is inherently insecure and offers no protection of its own. Do not expose this port to the world.") |
| reverseType = flag.String("reverse-type", "", "if non-empty, go into reverse mode where the buildlet dials the coordinator instead of listening for connections. The value is the dashboard/builders.go Hosts map key, naming a HostConfig. This buildlet will receive work for any BuildConfig specifying this named HostConfig.") |
| coordinator = flag.String("coordinator", "localhost:8119", "address of coordinator, in production use farmer.golang.org. Only used in reverse mode.") |
| hostname = flag.String("hostname", "", "hostname to advertise to coordinator for reverse mode; default is actual hostname") |
| ) |
| |
| // Bump this whenever something notable happens, or when another |
| // component needs a certain feature. This shows on the coordinator |
| // per reverse client, and is also accessible via the buildlet |
| // package's client API (via the Status method). |
| // |
| // Notable versions: |
| // 3: switched to revdial protocol |
| // 5: reverse dialing uses timeouts+tcp keepalives, pargzip fix |
| // 7: version bumps while debugging revdial hang (Issue 12816) |
| // 8: mac screensaver disabled |
| // 11: move from self-signed cert to LetsEncrypt (Issue 16442) |
| // 15: ssh support |
| // 16: make macstadium builders always haltEntireOS |
| // 17: make macstadium halts use sudo |
| // 18: set TMPDIR and GOCACHE |
| // 21: GO_BUILDER_SET_GOPROXY=coordinator support |
| // 22: TrimSpace the reverse buildlet's gobuildkey contents |
| // 23: revdial v2 |
| // 24: removeAllIncludingReadonly |
| // 25: use removeAllIncludingReadonly for all work area cleanup |
| const buildletVersion = 25 |
| |
| func defaultListenAddr() string { |
| if runtime.GOOS == "darwin" { |
| // Darwin will never run on GCE, so let's always |
| // listen on a high port (so we don't need to be |
| // root). |
| return ":5936" |
| } |
| // check if if env is dev |
| if !metadata.OnGCE() && !onEC2() { |
| return "localhost:5936" |
| } |
| // In production, default to port 80 or 443, depending on |
| // whether TLS is configured. |
| if metadataValue(metaKeyTLSCert) != "" { |
| return ":443" |
| } |
| return ":80" |
| } |
| |
| // Functionality set non-nil by some platforms: |
| var ( |
| osHalt func() |
| configureSerialLogOutput func() |
| setOSRlimit func() error |
| ) |
| |
| // If non-empty, the $TMPDIR and $GOCACHE environment variables to use |
| // for child processes. |
| var ( |
| processTmpDirEnv string |
| processGoCacheEnv string |
| ) |
| |
| const ( |
| metaKeyPassword = "password" |
| metaKeyTLSCert = "tls-cert" |
| metaKeyTLSkey = "tls-key" |
| ) |
| |
| func main() { |
| builderEnv := os.Getenv("GO_BUILDER_ENV") |
| |
| switch builderEnv { |
| case "macstadium_vm": |
| configureMacStadium() |
| case "linux-arm-arm5spacemonkey": |
| initBaseUnixEnv() // Issue 28041 |
| } |
| |
| onGCE := metadata.OnGCE() |
| switch runtime.GOOS { |
| case "plan9": |
| log.SetOutput(&plan9LogWriter{w: os.Stderr}) |
| case "linux": |
| if onGCE && !inKube { |
| if w, err := os.OpenFile("/dev/console", os.O_WRONLY, 0); err == nil { |
| log.SetOutput(w) |
| } |
| } |
| case "windows": |
| if onGCE { |
| configureSerialLogOutput() |
| } |
| } |
| |
| log.Printf("buildlet starting.") |
| flag.Parse() |
| |
| if builderEnv == "android-amd64-emu" { |
| startAndroidEmulator() |
| } |
| |
| // Optimize emphemeral filesystems. Prefer speed over safety, |
| // since these VMs only last for the duration of one build. |
| switch runtime.GOOS { |
| case "openbsd", "freebsd", "netbsd": |
| makeBSDFilesystemFast() |
| } |
| if setOSRlimit != nil { |
| err := setOSRlimit() |
| if err != nil { |
| log.Fatalf("setOSRLimit: %v", err) |
| } |
| log.Printf("set OS rlimits.") |
| } |
| |
| isReverse := *reverseType != "" |
| |
| if *listenAddr == "AUTO" && !isReverse { |
| v := defaultListenAddr() |
| log.Printf("Will listen on %s", v) |
| *listenAddr = v |
| } |
| |
| if !onGCE && !isReverse && !onEC2() && !strings.HasPrefix(*listenAddr, "localhost:") { |
| log.Printf("** WARNING *** This server is unsafe and offers no security. Be careful.") |
| } |
| if onGCE { |
| fixMTU() |
| } |
| if *workDir == "" && setWorkdirToTmpfs != nil { |
| setWorkdirToTmpfs() |
| } |
| if *workDir == "" { |
| switch runtime.GOOS { |
| case "windows": |
| // We want a short path on Windows, due to |
| // Windows issues with maximum path lengths. |
| *workDir = `C:\workdir` |
| if err := os.MkdirAll(*workDir, 0755); err != nil { |
| log.Fatalf("error creating workdir: %v", err) |
| } |
| default: |
| wdName := "workdir" |
| if *reverseType != "" { |
| wdName += "-" + *reverseType |
| } |
| dir := filepath.Join(os.TempDir(), wdName) |
| removeAllAndMkdir(dir) |
| *workDir = dir |
| } |
| } |
| |
| os.Setenv("WORKDIR", *workDir) // mostly for demos |
| |
| if _, err := os.Lstat(*workDir); err != nil { |
| log.Fatalf("invalid --workdir %q: %v", *workDir, err) |
| } |
| |
| // Set up and clean $TMPDIR and $GOCACHE directories. |
| if runtime.GOOS != "windows" && runtime.GOOS != "plan9" { |
| processTmpDirEnv = filepath.Join(*workDir, "tmp") |
| processGoCacheEnv = filepath.Join(*workDir, "gocache") |
| removeAllAndMkdir(processTmpDirEnv) |
| removeAllAndMkdir(processGoCacheEnv) |
| } |
| |
| initGorootBootstrap() |
| |
| http.HandleFunc("/", handleRoot) |
| http.HandleFunc("/debug/goroutines", handleGoroutines) |
| http.HandleFunc("/debug/x", handleX) |
| |
| var password string |
| if !isReverse { |
| password = metadataValue(metaKeyPassword) |
| } |
| requireAuth := func(handler func(w http.ResponseWriter, r *http.Request)) http.Handler { |
| return requirePasswordHandler{http.HandlerFunc(handler), password} |
| } |
| http.Handle("/writetgz", requireAuth(handleWriteTGZ)) |
| http.Handle("/write", requireAuth(handleWrite)) |
| http.Handle("/exec", requireAuth(handleExec)) |
| http.Handle("/halt", requireAuth(handleHalt)) |
| http.Handle("/tgz", requireAuth(handleGetTGZ)) |
| http.Handle("/removeall", requireAuth(handleRemoveAll)) |
| http.Handle("/workdir", requireAuth(handleWorkDir)) |
| http.Handle("/status", requireAuth(handleStatus)) |
| http.Handle("/ls", requireAuth(handleLs)) |
| http.Handle("/connect-ssh", requireAuth(handleConnectSSH)) |
| |
| if !isReverse { |
| listenForCoordinator() |
| } else { |
| if err := dialCoordinator(); err != nil { |
| log.Fatalf("Error dialing coordinator: %v", err) |
| } |
| log.Printf("buildlet reverse mode exiting.") |
| os.Exit(0) |
| } |
| } |
| |
| var inheritedGorootBootstrap string |
| |
| func initGorootBootstrap() { |
| // Remember any GOROOT_BOOTSTRAP to use as a backup in handleExec |
| // if $WORKDIR/go1.4 ends up not existing. |
| inheritedGorootBootstrap = os.Getenv("GOROOT_BOOTSTRAP") |
| |
| // Default if not otherwise configured in dashboard/builders.go: |
| os.Setenv("GOROOT_BOOTSTRAP", filepath.Join(*workDir, "go1.4")) |
| } |
| |
| func listenForCoordinator() { |
| tlsCert, tlsKey := metadataValue(metaKeyTLSCert), metadataValue(metaKeyTLSkey) |
| if (tlsCert == "") != (tlsKey == "") { |
| log.Fatalf("tls-cert and tls-key must both be supplied, or neither.") |
| } |
| |
| log.Printf("Listening on %s ...", *listenAddr) |
| ln, err := net.Listen("tcp", *listenAddr) |
| if err != nil { |
| log.Fatalf("Failed to listen on %s: %v", *listenAddr, err) |
| } |
| ln = tcpKeepAliveListener{ln.(*net.TCPListener)} |
| |
| var srv http.Server |
| if tlsCert != "" { |
| cert, err := tls.X509KeyPair([]byte(tlsCert), []byte(tlsKey)) |
| if err != nil { |
| log.Fatalf("TLS cert error: %v", err) |
| } |
| tlsConf := &tls.Config{ |
| Certificates: []tls.Certificate{cert}, |
| } |
| ln = tls.NewListener(ln, tlsConf) |
| } |
| |
| serveErr := make(chan error, 1) |
| go func() { |
| serveErr <- srv.Serve(ln) |
| }() |
| |
| signalChan := make(chan os.Signal, 1) |
| if registerSignal != nil { |
| registerSignal(signalChan) |
| } |
| select { |
| case sig := <-signalChan: |
| log.Printf("received signal %v; shutting down gracefully.", sig) |
| case err := <-serveErr: |
| log.Fatalf("Serve: %v", err) |
| } |
| time.AfterFunc(5*time.Second, func() { |
| log.Printf("timeout shutting down gracefully; exiting immediately") |
| os.Exit(1) |
| }) |
| if err := srv.Shutdown(context.Background()); err != nil { |
| log.Printf("Graceful shutdown error: %v; exiting immediately instead", err) |
| os.Exit(1) |
| } |
| log.Printf("graceful shutdown complete.") |
| os.Exit(0) |
| } |
| |
| // registerSignal if non-nil registers shutdown signals with the provided chan. |
| var registerSignal func(chan<- os.Signal) |
| |
| var inKube = os.Getenv("KUBERNETES_SERVICE_HOST") != "" |
| |
| var ( |
| // ec2UD contains a copy of the EC2 vm user data retrieved from the metadata. |
| ec2UD *cloud.EC2UserData |
| // ec2MdC is an EC2 metadata client. |
| ec2MdC *ec2metadata.EC2Metadata |
| ) |
| |
| // onEC2 evaluates if the buildlet is running on an EC2 instance. |
| func onEC2() bool { |
| if ec2MdC != nil { |
| return ec2MdC.Available() |
| } |
| ses, err := session.NewSession() |
| if err != nil { |
| log.Printf("unable to create aws session: %s", err) |
| return false |
| } |
| ec2MdC = ec2metadata.New(ses) |
| return ec2MdC.Available() |
| } |
| |
| // mdValueFromUserData maps a metadata key value into the corresponding |
| // EC2UserData value. If a mapping is not found, an empty string is returned. |
| func mdValueFromUserData(ud *cloud.EC2UserData, key string) string { |
| switch key { |
| case metaKeyTLSCert: |
| return ud.TLSCert |
| case metaKeyTLSkey: |
| return ud.TLSKey |
| case metaKeyPassword: |
| return ud.TLSPassword |
| default: |
| return "" |
| } |
| } |
| |
| // metadataValue returns the GCE metadata instance value for the given key. |
| // If the instance is on EC2 the corresponding value will be extracted from |
| // the user data available via the metadata. |
| // If the metadata is not defined, the returned string is empty. |
| // |
| // If not running on GCE or EC2, it falls back to using environment variables |
| // for local development. |
| func metadataValue(key string) string { |
| // The common case (on GCE, but not in Kubernetes): |
| if metadata.OnGCE() && !inKube { |
| v, err := metadata.InstanceAttributeValue(key) |
| if _, notDefined := err.(metadata.NotDefinedError); notDefined { |
| return "" |
| } |
| if err != nil { |
| log.Fatalf("metadata.InstanceAttributeValue(%q): %v", key, err) |
| } |
| return v |
| } |
| |
| if onEC2() { |
| if ec2UD != nil { |
| return mdValueFromUserData(ec2UD, key) |
| } |
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| defer cancel() |
| ec2MetaJson, err := ec2MdC.GetUserDataWithContext(ctx) |
| if err != nil { |
| log.Fatalf("unable to retrieve EC2 user data: %v", err) |
| } |
| ec2UD = &cloud.EC2UserData{} |
| err = json.Unmarshal([]byte(ec2MetaJson), ec2UD) |
| if err != nil { |
| log.Fatalf("unable to unmarshal user data json: %v", err) |
| } |
| return mdValueFromUserData(ec2UD, key) |
| } |
| |
| // Else allow use of environment variables to fake |
| // metadata keys, for Kubernetes pods or local testing. |
| envKey := "META_" + strings.Replace(key, "-", "_", -1) |
| v := os.Getenv(envKey) |
| // Respect curl-style '@' prefix to mean the rest is a filename. |
| if strings.HasPrefix(v, "@") { |
| slurp, err := ioutil.ReadFile(v[1:]) |
| if err != nil { |
| log.Fatalf("Error reading file for GCEMETA_%v: %v", key, err) |
| } |
| return string(slurp) |
| } |
| if v == "" { |
| log.Printf("Warning: not running on GCE, and no %v environment variable defined", envKey) |
| } |
| return v |
| } |
| |
| // tcpKeepAliveListener is a net.Listener that sets TCP keep-alive |
| // timeouts on accepted connections. |
| type tcpKeepAliveListener struct { |
| *net.TCPListener |
| } |
| |
| func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) { |
| tc, err := ln.AcceptTCP() |
| if err != nil { |
| return |
| } |
| tc.SetKeepAlive(true) |
| tc.SetKeepAlivePeriod(3 * time.Minute) |
| return tc, nil |
| } |
| |
| func fixMTU_freebsd() error { return fixMTU_ifconfig("vtnet0") } |
| func fixMTU_openbsd() error { return fixMTU_ifconfig("vio0") } |
| func fixMTU_ifconfig(iface string) error { |
| out, err := exec.Command("/sbin/ifconfig", iface, "mtu", "1460").CombinedOutput() |
| if err != nil { |
| return fmt.Errorf("/sbin/ifconfig %s mtu 1460: %v, %s", iface, err, out) |
| } |
| return nil |
| } |
| |
| func fixMTU_plan9() error { |
| f, err := os.OpenFile("/net/ipifc/0/ctl", os.O_WRONLY, 0) |
| if err != nil { |
| return err |
| } |
| if _, err := io.WriteString(f, "mtu 1460\n"); err != nil { |
| f.Close() |
| return err |
| } |
| return f.Close() |
| } |
| |
| func fixMTU() { |
| fn, ok := map[string]func() error{ |
| "openbsd": fixMTU_openbsd, |
| "freebsd": fixMTU_freebsd, |
| "plan9": fixMTU_plan9, |
| }[runtime.GOOS] |
| if ok { |
| if err := fn(); err != nil { |
| log.Printf("Failed to set MTU: %v", err) |
| } else { |
| log.Printf("Adjusted MTU.") |
| } |
| } |
| } |
| |
| // flushWriter is an io.Writer that Flushes after each Write if the |
| // underlying Writer implements http.Flusher. |
| type flushWriter struct { |
| rw http.ResponseWriter |
| } |
| |
| func (fw flushWriter) Write(p []byte) (n int, err error) { |
| n, err = fw.rw.Write(p) |
| if f, ok := fw.rw.(http.Flusher); ok { |
| f.Flush() |
| } |
| return |
| } |
| |
| func handleRoot(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path != "/" { |
| http.NotFound(w, r) |
| return |
| } |
| fmt.Fprintf(w, "buildlet running on %s-%s\n", runtime.GOOS, runtime.GOARCH) |
| } |
| |
| // unauthenticated /debug/goroutines handler |
| func handleGoroutines(w http.ResponseWriter, r *http.Request) { |
| log.Printf("Dumping goroutines.") |
| w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| buf := make([]byte, 2<<20) |
| buf = buf[:runtime.Stack(buf, true)] |
| w.Write(buf) |
| log.Printf("Dumped goroutines.") |
| } |
| |
| // unauthenticated /debug/x handler, to test MTU settings. |
| func handleX(w http.ResponseWriter, r *http.Request) { |
| n, _ := strconv.Atoi(r.FormValue("n")) |
| if n > 1<<20 { |
| n = 1 << 20 |
| } |
| log.Printf("Dumping %d X.", n) |
| w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| buf := make([]byte, n) |
| for i := range buf { |
| buf[i] = 'X' |
| } |
| w.Write(buf) |
| log.Printf("Dumped X.") |
| } |
| |
| // This is a remote code execution daemon, so security is kinda pointless, but: |
| func validRelativeDir(dir string) bool { |
| if strings.Contains(dir, `\`) || path.IsAbs(dir) { |
| return false |
| } |
| dir = path.Clean(dir) |
| if strings.HasPrefix(dir, "../") || strings.HasSuffix(dir, "/..") || dir == ".." { |
| return false |
| } |
| return true |
| } |
| |
| func handleGetTGZ(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "GET" { |
| http.Error(w, "requires GET method", http.StatusBadRequest) |
| return |
| } |
| if !mkdirAllWorkdirOr500(w) { |
| return |
| } |
| dir := r.FormValue("dir") |
| if !validRelativeDir(dir) { |
| http.Error(w, "bogus dir", http.StatusBadRequest) |
| return |
| } |
| var zw io.WriteCloser |
| if r.FormValue("pargzip") == "0" { |
| zw = gzip.NewWriter(w) |
| } else { |
| zw = pargzip.NewWriter(w) |
| } |
| tw := tar.NewWriter(zw) |
| base := filepath.Join(*workDir, filepath.FromSlash(dir)) |
| err := filepath.Walk(base, func(path string, fi os.FileInfo, err error) error { |
| if err != nil { |
| return err |
| } |
| rel := strings.TrimPrefix(filepath.ToSlash(strings.TrimPrefix(path, base)), "/") |
| var linkName string |
| if fi.Mode()&os.ModeSymlink != 0 { |
| linkName, err = os.Readlink(path) |
| if err != nil { |
| return err |
| } |
| } |
| th, err := tar.FileInfoHeader(fi, linkName) |
| if err != nil { |
| return err |
| } |
| th.Name = rel |
| if fi.IsDir() && !strings.HasSuffix(th.Name, "/") { |
| th.Name += "/" |
| } |
| if th.Name == "/" { |
| return nil |
| } |
| if err := tw.WriteHeader(th); err != nil { |
| return err |
| } |
| if fi.Mode().IsRegular() { |
| f, err := os.Open(path) |
| if err != nil { |
| return err |
| } |
| defer f.Close() |
| if _, err := io.Copy(tw, f); err != nil { |
| return err |
| } |
| } |
| return nil |
| }) |
| if err != nil { |
| log.Printf("Walk error: %v", err) |
| panic(http.ErrAbortHandler) |
| } |
| tw.Close() |
| zw.Close() |
| } |
| |
| func handleWriteTGZ(w http.ResponseWriter, r *http.Request) { |
| if !mkdirAllWorkdirOr500(w) { |
| return |
| } |
| urlParam, _ := url.ParseQuery(r.URL.RawQuery) |
| baseDir := *workDir |
| if dir := urlParam.Get("dir"); dir != "" { |
| if !validRelativeDir(dir) { |
| log.Printf("writetgz: bogus dir %q", dir) |
| http.Error(w, "bogus dir", http.StatusBadRequest) |
| return |
| } |
| dir = filepath.FromSlash(dir) |
| baseDir = filepath.Join(baseDir, dir) |
| |
| // Special case: if the directory is "go1.4" and it already exists, do nothing. |
| // This lets clients do a blind write to it and not do extra work. |
| if r.Method == "POST" && dir == "go1.4" { |
| if fi, err := os.Stat(baseDir); err == nil && fi.IsDir() { |
| log.Printf("writetgz: skipping URL puttar to go1.4 dir; already exists") |
| io.WriteString(w, "SKIP") |
| return |
| } |
| } |
| |
| if err := os.MkdirAll(baseDir, 0755); err != nil { |
| log.Printf("writetgz: %v", err) |
| http.Error(w, "mkdir of base: "+err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| var tgz io.Reader |
| var urlStr string |
| switch r.Method { |
| case "PUT": |
| tgz = r.Body |
| log.Printf("writetgz: untarring Request.Body into %s", baseDir) |
| case "POST": |
| urlStr = r.FormValue("url") |
| if urlStr == "" { |
| log.Printf("writetgz: missing url POST param") |
| http.Error(w, "missing url POST param", http.StatusBadRequest) |
| return |
| } |
| t0 := time.Now() |
| res, err := http.Get(urlStr) |
| if err != nil { |
| log.Printf("writetgz: failed to fetch tgz URL %s: %v", urlStr, err) |
| http.Error(w, fmt.Sprintf("fetching URL %s: %v", urlStr, err), http.StatusInternalServerError) |
| return |
| } |
| defer res.Body.Close() |
| if res.StatusCode != http.StatusOK { |
| log.Printf("writetgz: failed to fetch tgz URL %s: status=%v", urlStr, res.Status) |
| http.Error(w, fmt.Sprintf("writetgz: fetching provided URL %q: %s", urlStr, res.Status), http.StatusInternalServerError) |
| return |
| } |
| tgz = res.Body |
| log.Printf("writetgz: untarring %s (got headers in %v) into %s", urlStr, time.Since(t0), baseDir) |
| default: |
| log.Printf("writetgz: invalid method %q", r.Method) |
| http.Error(w, "requires PUT or POST method", http.StatusBadRequest) |
| return |
| } |
| |
| err := untar(tgz, baseDir) |
| if err != nil { |
| status := http.StatusInternalServerError |
| if he, ok := err.(httpStatuser); ok { |
| status = he.httpStatus() |
| } |
| http.Error(w, err.Error(), status) |
| return |
| } |
| io.WriteString(w, "OK") |
| } |
| |
| func handleWrite(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "PUT" { |
| http.Error(w, "requires POST method", http.StatusBadRequest) |
| return |
| } |
| |
| param, _ := url.ParseQuery(r.URL.RawQuery) |
| |
| path := param.Get("path") |
| if path == "" || !validRelPath(path) { |
| http.Error(w, "bad path", http.StatusBadRequest) |
| return |
| } |
| path = filepath.FromSlash(path) |
| path = filepath.Join(*workDir, path) |
| |
| modeInt, err := strconv.ParseInt(param.Get("mode"), 10, 64) |
| mode := os.FileMode(modeInt) |
| if err != nil || !mode.IsRegular() { |
| http.Error(w, "bad mode", http.StatusBadRequest) |
| return |
| } |
| |
| // Make the directory if it doesn't exist. |
| // TODO(adg): support dirmode parameter? |
| if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| |
| if err := writeFile(r.Body, path, mode); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| |
| io.WriteString(w, "OK") |
| } |
| |
| func writeFile(r io.Reader, path string, mode os.FileMode) error { |
| f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) |
| if err != nil { |
| return err |
| } |
| if _, err := io.Copy(f, r); err != nil { |
| f.Close() |
| return err |
| } |
| // Try to set the mode again, in case the file already existed. |
| if runtime.GOOS != "windows" { |
| if err := f.Chmod(mode); err != nil { |
| f.Close() |
| return err |
| } |
| } |
| return f.Close() |
| } |
| |
| // untar reads the gzip-compressed tar file from r and writes it into dir. |
| func untar(r io.Reader, dir string) (err error) { |
| t0 := time.Now() |
| nFiles := 0 |
| madeDir := map[string]bool{} |
| defer func() { |
| td := time.Since(t0) |
| if err == nil { |
| log.Printf("extracted tarball into %s: %d files, %d dirs (%v)", dir, nFiles, len(madeDir), td) |
| } else { |
| log.Printf("error extracting tarball into %s after %d files, %d dirs, %v: %v", dir, nFiles, len(madeDir), td, err) |
| } |
| }() |
| zr, err := gzip.NewReader(r) |
| if err != nil { |
| return badRequest("requires gzip-compressed body: " + err.Error()) |
| } |
| tr := tar.NewReader(zr) |
| loggedChtimesError := false |
| for { |
| f, err := tr.Next() |
| if err == io.EOF { |
| break |
| } |
| if err != nil { |
| log.Printf("tar reading error: %v", err) |
| return badRequest("tar error: " + err.Error()) |
| } |
| if f.Typeflag == tar.TypeXGlobalHeader { |
| // golang.org/issue/22748: git archive exports |
| // a global header ('g') which after Go 1.9 |
| // (for a bit?) contained an empty filename. |
| // Ignore it. |
| continue |
| } |
| if !validRelPath(f.Name) { |
| return badRequest(fmt.Sprintf("tar file contained invalid name %q", f.Name)) |
| } |
| rel := filepath.FromSlash(f.Name) |
| abs := filepath.Join(dir, rel) |
| |
| fi := f.FileInfo() |
| mode := fi.Mode() |
| switch { |
| case mode.IsRegular(): |
| // Make the directory. This is redundant because it should |
| // already be made by a directory entry in the tar |
| // beforehand. Thus, don't check for errors; the next |
| // write will fail with the same error. |
| dir := filepath.Dir(abs) |
| if !madeDir[dir] { |
| if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { |
| return err |
| } |
| madeDir[dir] = true |
| } |
| wf, err := os.OpenFile(abs, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode.Perm()) |
| if err != nil { |
| return err |
| } |
| n, err := io.Copy(wf, tr) |
| if closeErr := wf.Close(); closeErr != nil && err == nil { |
| err = closeErr |
| } |
| if err != nil { |
| return fmt.Errorf("error writing to %s: %v", abs, err) |
| } |
| if n != f.Size { |
| return fmt.Errorf("only wrote %d bytes to %s; expected %d", n, abs, f.Size) |
| } |
| modTime := f.ModTime |
| if modTime.After(t0) { |
| // Clamp modtimes at system time. See |
| // golang.org/issue/19062 when clock on |
| // buildlet was behind the gitmirror server |
| // doing the git-archive. |
| modTime = t0 |
| } |
| if !modTime.IsZero() { |
| if err := os.Chtimes(abs, modTime, modTime); err != nil && !loggedChtimesError { |
| // benign error. Gerrit doesn't even set the |
| // modtime in these, and we don't end up relying |
| // on it anywhere (the gomote push command relies |
| // on digests only), so this is a little pointless |
| // for now. |
| log.Printf("error changing modtime: %v (further Chtimes errors suppressed)", err) |
| loggedChtimesError = true // once is enough |
| } |
| } |
| nFiles++ |
| case mode.IsDir(): |
| if err := os.MkdirAll(abs, 0755); err != nil { |
| return err |
| } |
| madeDir[abs] = true |
| case mode&os.ModeSymlink != 0: |
| // TODO: ignore these for now. They were breaking x/build tests. |
| // Implement these if/when we ever have a test that needs them. |
| // But maybe we'd have to skip creating them on Windows for some builders |
| // without permissions. |
| default: |
| return badRequest(fmt.Sprintf("tar file entry %s contained unsupported file type %v", f.Name, mode)) |
| } |
| } |
| return nil |
| } |
| |
| // Process-State is an HTTP Trailer set in the /exec handler to "ok" |
| // on success, or os.ProcessState.String() on failure. |
| const hdrProcessState = "Process-State" |
| |
| func handleExec(w http.ResponseWriter, r *http.Request) { |
| cn := w.(http.CloseNotifier) |
| clientGone := cn.CloseNotify() |
| handlerDone := make(chan bool) |
| defer close(handlerDone) |
| |
| if r.Method != "POST" { |
| http.Error(w, "requires POST method", http.StatusBadRequest) |
| return |
| } |
| if r.ProtoMajor*10+r.ProtoMinor < 11 { |
| // We need trailers, only available in HTTP/1.1 or HTTP/2. |
| http.Error(w, "HTTP/1.1 or higher required", http.StatusBadRequest) |
| return |
| } |
| // Create *workDir and (if needed) tmp and gocache. |
| if !mkdirAllWorkdirOr500(w) { |
| return |
| } |
| for _, dir := range []string{processTmpDirEnv, processGoCacheEnv} { |
| if dir == "" { |
| continue |
| } |
| if err := os.MkdirAll(dir, 0755); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| if err := checkAndroidEmulator(); err != nil { |
| http.Error(w, "android emulator not running: "+err.Error(), http.StatusInternalServerError) |
| return |
| } |
| |
| w.Header().Set("Trailer", hdrProcessState) // declare it so we can set it |
| |
| cmdPath := r.FormValue("cmd") // required |
| absCmd := cmdPath |
| dir := r.FormValue("dir") // optional |
| sysMode := r.FormValue("mode") == "sys" |
| debug, _ := strconv.ParseBool(r.FormValue("debug")) |
| |
| if sysMode { |
| if cmdPath == "" { |
| http.Error(w, "requires 'cmd' parameter", http.StatusBadRequest) |
| return |
| } |
| if dir == "" { |
| dir = *workDir |
| } else { |
| dir = filepath.FromSlash(dir) |
| if !filepath.IsAbs(dir) { |
| dir = filepath.Join(*workDir, dir) |
| } |
| } |
| } else { |
| if !validRelPath(cmdPath) { |
| http.Error(w, "requires 'cmd' parameter", http.StatusBadRequest) |
| return |
| } |
| absCmd = filepath.Join(*workDir, filepath.FromSlash(cmdPath)) |
| if dir == "" { |
| dir = filepath.Dir(absCmd) |
| } else { |
| if !validRelPath(dir) { |
| http.Error(w, "bogus 'dir' parameter", http.StatusBadRequest) |
| return |
| } |
| dir = filepath.Join(*workDir, filepath.FromSlash(dir)) |
| } |
| } |
| |
| if f, ok := w.(http.Flusher); ok { |
| f.Flush() |
| } |
| |
| postEnv := r.PostForm["env"] |
| |
| goarch := "amd64" // unless we find otherwise |
| if v := getEnv(postEnv, "GOARCH"); v != "" { |
| goarch = v |
| } |
| if v, _ := strconv.ParseBool(getEnv(postEnv, "GO_DISABLE_OUTBOUND_NETWORK")); v { |
| disableOutboundNetwork() |
| } |
| |
| env := append(baseEnv(goarch), postEnv...) |
| |
| if v := processTmpDirEnv; v != "" { |
| env = append(env, "TMPDIR="+v) |
| } |
| if v := processGoCacheEnv; v != "" { |
| env = append(env, "GOCACHE="+v) |
| } |
| |
| // Prefer buildlet process's inherited GOROOT_BOOTSTRAP if |
| // there was one and the one we're about to use doesn't exist. |
| if v := getEnv(env, "GOROOT_BOOTSTRAP"); v != "" && inheritedGorootBootstrap != "" && pathNotExist(v) { |
| env = append(env, "GOROOT_BOOTSTRAP="+inheritedGorootBootstrap) |
| } |
| env = setPathEnv(env, r.PostForm["path"], *workDir) |
| |
| var cmd *exec.Cmd |
| if needsBashWrapper(absCmd) { |
| cmd = exec.Command("bash", absCmd) |
| } else { |
| cmd = exec.Command(absCmd) |
| } |
| cmd.Args = append(cmd.Args, r.PostForm["cmdArg"]...) |
| cmd.Dir = dir |
| cmdOutput := flushWriter{w} |
| cmd.Stdout = cmdOutput |
| cmd.Stderr = cmdOutput |
| cmd.Env = env |
| |
| log.Printf("[%p] Running %s with args %q and env %q in dir %s", |
| cmd, cmd.Path, cmd.Args, cmd.Env, cmd.Dir) |
| |
| if debug { |
| fmt.Fprintf(cmdOutput, ":: Running %s with args %q and env %q in dir %s\n\n", |
| cmd.Path, cmd.Args, cmd.Env, cmd.Dir) |
| } |
| |
| t0 := time.Now() |
| err := cmd.Start() |
| if err == nil { |
| go func() { |
| select { |
| case <-clientGone: |
| err := killProcessTree(cmd.Process) |
| if err != nil { |
| log.Printf("Kill failed: %v", err) |
| } |
| case <-handlerDone: |
| return |
| } |
| }() |
| err = cmd.Wait() |
| } |
| state := "ok" |
| if err != nil { |
| if ps := cmd.ProcessState; ps != nil { |
| state = ps.String() |
| } else { |
| state = err.Error() |
| } |
| } |
| w.Header().Set(hdrProcessState, state) |
| log.Printf("[%p] Run = %s, after %v", cmd, state, time.Since(t0)) |
| } |
| |
| // needsBashWrappers reports whether the given command needs to |
| // run through bash. |
| func needsBashWrapper(cmd string) bool { |
| if !strings.HasSuffix(cmd, ".bash") { |
| return false |
| } |
| // The mobile platforms can't execute shell scripts directly. |
| ismobile := runtime.GOOS == "android" || runtime.GOOS == "ios" |
| return ismobile |
| } |
| |
| // pathNotExist reports whether path does not exist. |
| func pathNotExist(path string) bool { |
| _, err := os.Stat(path) |
| return os.IsNotExist(err) |
| } |
| |
| func getEnv(env []string, key string) string { |
| for _, kv := range env { |
| if len(kv) <= len(key) || kv[len(key)] != '=' { |
| continue |
| } |
| if runtime.GOOS == "windows" { |
| // Case insensitive. |
| if strings.EqualFold(kv[:len(key)], key) { |
| return kv[len(key)+1:] |
| } |
| } else { |
| // Case sensitive. |
| if kv[:len(key)] == key { |
| return kv[len(key)+1:] |
| } |
| } |
| } |
| return "" |
| } |
| |
| // setPathEnv returns a copy of the provided environment with any existing |
| // PATH variables replaced by the user-provided path. |
| // These substitutions are applied to user-supplied path elements: |
| // - the string "$PATH" expands to the original PATH elements |
| // - the substring "$WORKDIR" expands to the provided workDir |
| // A path of just ["$EMPTY"] removes the PATH variable from the environment. |
| func setPathEnv(env, path []string, workDir string) []string { |
| if len(path) == 0 { |
| return env |
| } |
| |
| var ( |
| pathIdx = -1 |
| pathOrig = "" |
| ) |
| |
| for i, s := range env { |
| if isPathEnvPair(s) { |
| pathIdx = i |
| pathOrig = s[len("PaTh="):] // in whatever case |
| break |
| } |
| } |
| if len(path) == 1 && path[0] == "$EMPTY" { |
| // Remove existing path variable if it exists. |
| if pathIdx >= 0 { |
| env = append(env[:pathIdx], env[pathIdx+1:]...) |
| } |
| return env |
| } |
| |
| // Apply substitions to a copy of the path argument. |
| path = append([]string{}, path...) |
| for i, s := range path { |
| if s == "$PATH" { |
| path[i] = pathOrig // ok if empty |
| } else { |
| path[i] = strings.Replace(s, "$WORKDIR", workDir, -1) |
| } |
| } |
| |
| // Put the new PATH in env. |
| env = append([]string{}, env...) |
| pathEnv := pathEnvVar() + "=" + strings.Join(path, pathSeparator()) |
| if pathIdx >= 0 { |
| env[pathIdx] = pathEnv |
| } else { |
| env = append(env, pathEnv) |
| } |
| |
| return env |
| } |
| |
| // isPathEnvPair reports whether the key=value pair s represents |
| // the operating system's path variable. |
| func isPathEnvPair(s string) bool { |
| // On Unix it's PATH. |
| // On Plan 9 it's path. |
| // On Windows it's pAtH case-insensitive. |
| if runtime.GOOS == "windows" { |
| return len(s) >= 5 && strings.EqualFold(s[:5], "PATH=") |
| } |
| if runtime.GOOS == "plan9" { |
| return strings.HasPrefix(s, "path=") |
| } |
| return strings.HasPrefix(s, "PATH=") |
| } |
| |
| // On Unix it's PATH. |
| // On Plan 9 it's path. |
| // On Windows it's pAtH case-insensitive. |
| func pathEnvVar() string { |
| if runtime.GOOS == "plan9" { |
| return "path" |
| } |
| return "PATH" |
| } |
| |
| func pathSeparator() string { |
| if runtime.GOOS == "plan9" { |
| return "\x00" |
| } else { |
| return string(filepath.ListSeparator) |
| } |
| } |
| |
| func baseEnv(goarch string) []string { |
| if runtime.GOOS == "windows" { |
| return windowsBaseEnv(goarch) |
| } |
| return os.Environ() |
| } |
| |
| func windowsBaseEnv(goarch string) (e []string) { |
| e = append(e, "GOBUILDEXIT=1") // exit all.bat with completion status |
| |
| is64 := goarch != "386" |
| for _, pair := range os.Environ() { |
| const pathEq = "PATH=" |
| if hasPrefixFold(pair, pathEq) { |
| e = append(e, "PATH="+windowsPath(pair[len(pathEq):], is64)) |
| } else { |
| e = append(e, pair) |
| } |
| } |
| return e |
| } |
| |
| // hasPrefixFold is a case-insensitive strings.HasPrefix. |
| func hasPrefixFold(s, prefix string) bool { |
| return len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) |
| } |
| |
| // windowsPath cleans the windows %PATH% environment. |
| // is64Bit is whether this is a windows-amd64-* builder. |
| // The PATH is assumed to be that of the image described in env/windows/README. |
| func windowsPath(old string, is64Bit bool) string { |
| vv := filepath.SplitList(old) |
| newPath := make([]string, 0, len(vv)) |
| |
| // for windows-buildlet-v2 images |
| for _, v := range vv { |
| // The base VM image has both the 32-bit and 64-bit gcc installed. |
| // They're both in the environment, so scrub the one |
| // we don't want (TDM-GCC-64 or TDM-GCC-32). |
| if strings.Contains(v, "TDM-GCC-") { |
| gcc64 := strings.Contains(v, "TDM-GCC-64") |
| if is64Bit != gcc64 { |
| continue |
| } |
| } |
| newPath = append(newPath, v) |
| } |
| |
| // for windows-amd64-* images |
| if is64Bit { |
| newPath = append(newPath, `C:\godep\gcc64\bin`) |
| } else { |
| newPath = append(newPath, `C:\godep\gcc32\bin`) |
| } |
| |
| return strings.Join(newPath, string(filepath.ListSeparator)) |
| } |
| |
| func handleHalt(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "POST" { |
| http.Error(w, "requires POST method", http.StatusBadRequest) |
| return |
| } |
| |
| // Do the halt in 1 second, to give the HTTP response time to |
| // complete. |
| // |
| // TODO(bradfitz): maybe prevent any (unlikely) future HTTP |
| // requests from doing anything from this point on in the |
| // remaining second. |
| log.Printf("Halting in 1 second.") |
| time.AfterFunc(1*time.Second, doHalt) |
| } |
| |
| func doHalt() { |
| if *rebootOnHalt { |
| if err := exec.Command("reboot").Run(); err != nil { |
| log.Printf("Error running reboot: %v", err) |
| } |
| os.Exit(0) |
| } |
| if !*haltEntireOS { |
| log.Printf("Ending buildlet process due to halt.") |
| os.Exit(0) |
| return |
| } |
| log.Printf("Halting machine.") |
| time.AfterFunc(5*time.Second, func() { os.Exit(0) }) |
| if osHalt != nil { |
| // TODO: Windows: http://msdn.microsoft.com/en-us/library/windows/desktop/aa376868%28v=vs.85%29.aspx |
| osHalt() |
| os.Exit(0) |
| } |
| // Backup mechanism, if exec hangs for any reason: |
| var err error |
| switch runtime.GOOS { |
| case "openbsd": |
| // Quick, no fs flush, and power down: |
| err = exec.Command("halt", "-q", "-n", "-p").Run() |
| case "freebsd": |
| // Power off (-p), via halt (-o), now. |
| err = exec.Command("shutdown", "-p", "-o", "now").Run() |
| case "linux": |
| // Don't sync (-n), force without shutdown (-f), and power off (-p). |
| err = exec.Command("/bin/halt", "-n", "-f", "-p").Run() |
| case "plan9": |
| err = exec.Command("fshalt").Run() |
| case "darwin": |
| if os.Getenv("GO_BUILDER_ENV") == "macstadium_vm" { |
| // Fast, sloppy, unsafe, because we're never reusing this VM again. |
| err = exec.Command("/usr/bin/sudo", "/sbin/halt", "-n", "-q", "-l").Run() |
| } else { |
| err = errors.New("not respecting -halt flag on macOS in unknown environment") |
| } |
| default: |
| err = errors.New("no system-specific halt command run; will just end buildlet process") |
| } |
| log.Printf("Shutdown: %v", err) |
| log.Printf("Ending buildlet process post-halt") |
| os.Exit(0) |
| } |
| |
| func handleRemoveAll(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "POST" { |
| http.Error(w, "requires POST method", http.StatusBadRequest) |
| return |
| } |
| if err := r.ParseForm(); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| paths := r.Form["path"] |
| if len(paths) == 0 { |
| http.Error(w, "requires 'path' parameter", http.StatusBadRequest) |
| return |
| } |
| for _, p := range paths { |
| if !validRelPath(p) { |
| http.Error(w, fmt.Sprintf("bad 'path' parameter: %q", p), http.StatusBadRequest) |
| return |
| } |
| } |
| for _, p := range paths { |
| log.Printf("Removing %s", p) |
| fullDir := filepath.Join(*workDir, filepath.FromSlash(p)) |
| err := removeAllIncludingReadonly(fullDir) |
| if p == "." && err != nil { |
| // If workDir is a mountpoint and/or contains a binary |
| // using it, we can get a "Device or resource busy" error. |
| // See if it's now empty and ignore the error. |
| if f, oerr := os.Open(*workDir); oerr == nil { |
| if all, derr := f.Readdirnames(-1); derr == nil && len(all) == 0 { |
| log.Printf("Ignoring fail of RemoveAll(.)") |
| err = nil |
| } else { |
| log.Printf("Readdir = %q, %v", all, derr) |
| } |
| f.Close() |
| } else { |
| log.Printf("Failed to open workdir: %v", oerr) |
| } |
| } |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| } |
| |
| // mkdirAllWorkdirOr500 reports whether *workDir either exists or was created. |
| // If it returns false, it also writes an HTTP 500 error to w. |
| // This is used by callers to verify *workDir exists, even if it might've been |
| // deleted previously. |
| func mkdirAllWorkdirOr500(w http.ResponseWriter) bool { |
| if err := os.MkdirAll(*workDir, 0755); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return false |
| } |
| return true |
| } |
| |
| func handleWorkDir(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "GET" { |
| http.Error(w, "requires GET method", http.StatusBadRequest) |
| return |
| } |
| fmt.Fprint(w, *workDir) |
| } |
| |
| func handleStatus(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "GET" { |
| http.Error(w, "requires GET method", http.StatusBadRequest) |
| return |
| } |
| status := buildlet.Status{ |
| Version: buildletVersion, |
| } |
| b, err := json.Marshal(status) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| w.Write(b) |
| } |
| |
| func handleLs(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "GET" { |
| http.Error(w, "requires GET method", http.StatusBadRequest) |
| return |
| } |
| dir := r.FormValue("dir") |
| recursive, _ := strconv.ParseBool(r.FormValue("recursive")) |
| digest, _ := strconv.ParseBool(r.FormValue("digest")) |
| skip := r.Form["skip"] // '/'-separated relative dirs |
| |
| if !mkdirAllWorkdirOr500(w) { |
| return |
| } |
| if !validRelativeDir(dir) { |
| http.Error(w, "bogus dir", http.StatusBadRequest) |
| return |
| } |
| base := filepath.Join(*workDir, filepath.FromSlash(dir)) |
| anyOutput := false |
| err := filepath.Walk(base, func(path string, fi os.FileInfo, err error) error { |
| if err != nil { |
| return err |
| } |
| rel := strings.TrimPrefix(filepath.ToSlash(strings.TrimPrefix(path, base)), "/") |
| if rel == "" && fi.IsDir() { |
| return nil |
| } |
| if fi.IsDir() { |
| for _, v := range skip { |
| if rel == v { |
| return filepath.SkipDir |
| } |
| } |
| } |
| anyOutput = true |
| fmt.Fprintf(w, "%s\t%s", fi.Mode(), rel) |
| if fi.Mode().IsRegular() { |
| fmt.Fprintf(w, "\t%d\t%s", fi.Size(), fi.ModTime().UTC().Format(time.RFC3339)) |
| if digest { |
| if sha1, err := fileSHA1(path); err != nil { |
| return err |
| } else { |
| io.WriteString(w, "\t"+sha1) |
| } |
| } |
| } else if fi.Mode().IsDir() { |
| io.WriteString(w, "/") |
| } |
| io.WriteString(w, "\n") |
| if fi.IsDir() && !recursive { |
| return filepath.SkipDir |
| } |
| return nil |
| }) |
| if err != nil { |
| log.Printf("Walk error: %v", err) |
| if anyOutput { |
| // Decent way to signal failure to the caller, since it'll break |
| // the chunked response, rather than have a valid EOF. |
| conn, _, _ := w.(http.Hijacker).Hijack() |
| conn.Close() |
| return |
| } |
| http.Error(w, "Walk error: "+err.Error(), 500) |
| return |
| } |
| } |
| |
| func handleConnectSSH(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "POST" { |
| http.Error(w, "requires POST method", http.StatusBadRequest) |
| return |
| } |
| if r.ContentLength != 0 { |
| http.Error(w, "requires zero Content-Length", http.StatusBadRequest) |
| return |
| } |
| sshUser := r.Header.Get("X-Go-Ssh-User") |
| authKey := r.Header.Get("X-Go-Authorized-Key") |
| if sshUser != "" && authKey != "" { |
| if err := appendSSHAuthorizedKey(sshUser, authKey); err != nil { |
| http.Error(w, "adding ssh authorized key: "+err.Error(), http.StatusBadRequest) |
| return |
| } |
| } |
| |
| sshServerOnce.Do(startSSHServer) |
| |
| var sshConn net.Conn |
| var err error |
| |
| // In theory we shouldn't need retries here at all, but the |
| // startSSHServerLinux's use of sshd -D is kinda sketchy and |
| // restarts the process whenever we connect to it, so in case |
| // it's just down between restarts, try a few times. 5 tries |
| // and 5 seconds seems plenty. |
| const maxTries = 5 |
| for try := 1; try <= maxTries; try++ { |
| sshConn, err = net.Dial("tcp", "localhost:"+sshPort()) |
| if err == nil { |
| break |
| } |
| if try == maxTries { |
| http.Error(w, err.Error(), http.StatusBadGateway) |
| return |
| } |
| time.Sleep(time.Second) |
| } |
| defer sshConn.Close() |
| hj, ok := w.(http.Hijacker) |
| if !ok { |
| log.Printf("conn can't hijack for ssh proxy; HTTP/2 enabled by default?") |
| http.Error(w, "conn can't hijack", http.StatusInternalServerError) |
| return |
| } |
| conn, _, err := hj.Hijack() |
| if err != nil { |
| log.Printf("ssh hijack error: %v", err) |
| http.Error(w, "ssh hijack error: "+err.Error(), http.StatusInternalServerError) |
| return |
| } |
| defer conn.Close() |
| fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: ssh\r\nConnection: Upgrade\r\n\r\n") |
| errc := make(chan error, 1) |
| go func() { |
| _, err := io.Copy(sshConn, conn) |
| errc <- err |
| }() |
| go func() { |
| _, err := io.Copy(conn, sshConn) |
| errc <- err |
| }() |
| <-errc |
| } |
| |
| // sshPort returns the port to use for the local SSH server. |
| func sshPort() string { |
| // runningInCOS is whether we're running under GCE's Container-Optimized OS (COS). |
| const runningInCOS = runtime.GOOS == "linux" && runtime.GOARCH == "amd64" |
| |
| if runningInCOS { |
| // If running in COS, we can't use port 22, as the system's sshd is already using it. |
| // Our container runs in the system network namespace, not isolated as is typical |
| // in Docker or Kubernetes. So use another high port. See https://golang.org/issue/26969. |
| return "2200" |
| } |
| return "22" |
| } |
| |
| var sshServerOnce sync.Once |
| |
| // startSSHServer starts an SSH server. |
| func startSSHServer() { |
| if inLinuxContainer() { |
| startSSHServerLinux() |
| return |
| } |
| if runtime.GOOS == "netbsd" { |
| startSSHServerNetBSD() |
| return |
| } |
| |
| log.Printf("start ssh server: don't know how to start SSH server on this host type") |
| } |
| |
| // inLinuxContainer reports whether it looks like we're on Linux running inside a container. |
| func inLinuxContainer() bool { |
| if runtime.GOOS != "linux" { |
| return false |
| } |
| if numProcs() >= 4 { |
| // There should 1 process running (this buildlet |
| // binary) if we're in Docker. Maybe 2 if something |
| // else is happening. But if there are 4 or more, |
| // we'll be paranoid and assuming we're running on a |
| // user or host system and don't want to start an ssh |
| // server. |
| return false |
| } |
| // TODO: use a more explicit env variable or on-disk signal |
| // that we're in a Go buildlet Docker image. But for now, this |
| // seems to be consistently true: |
| fi, err := os.Stat("/usr/local/bin/stage0") |
| return err == nil && fi.Mode().IsRegular() |
| } |
| |
| // startSSHServerLinux starts an SSH server on a Linux system. |
| func startSSHServerLinux() { |
| log.Printf("start ssh server for linux") |
| |
| // First, create the privsep directory, otherwise we get a successful cmd.Start, |
| // but this error message and then an exit: |
| // Missing privilege separation directory: /var/run/sshd |
| if err := os.MkdirAll("/var/run/sshd", 0700); err != nil { |
| log.Printf("creating /var/run/sshd: %v", err) |
| return |
| } |
| |
| // The scaleway Docker images don't have ssh host keys in |
| // their image, at least as of 2017-07-23. So make them first. |
| // These are the types sshd -D complains about currently. |
| if runtime.GOARCH == "arm" { |
| for _, keyType := range []string{"rsa", "dsa", "ed25519", "ecdsa"} { |
| file := "/etc/ssh/ssh_host_" + keyType + "_key" |
| if _, err := os.Stat(file); err == nil { |
| continue |
| } |
| out, err := exec.Command("/usr/bin/ssh-keygen", "-f", file, "-N", "", "-t", keyType).CombinedOutput() |
| log.Printf("ssh-keygen of type %s: err=%v, %s\n", keyType, err, out) |
| } |
| } |
| |
| go func() { |
| for { |
| // TODO: using sshd -D isn't great as it only |
| // handles a single connection and exits. |
| // Maybe run in sshd -i (inetd) mode instead, |
| // and hook that up to the buildlet directly? |
| t0 := time.Now() |
| cmd := exec.Command("/usr/sbin/sshd", "-D", "-p", sshPort(), "-d", "-d") |
| cmd.Stderr = os.Stderr |
| err := cmd.Start() |
| if err != nil { |
| log.Printf("starting sshd: %v", err) |
| return |
| } |
| log.Printf("sshd started.") |
| log.Printf("sshd exited: %v; restarting", cmd.Wait()) |
| if d := time.Since(t0); d < time.Second { |
| time.Sleep(time.Second - d) |
| } |
| } |
| }() |
| waitLocalSSH() |
| } |
| |
| func startSSHServerNetBSD() { |
| cmd := exec.Command("/etc/rc.d/sshd", "start") |
| err := cmd.Start() |
| if err != nil { |
| log.Printf("starting sshd: %v", err) |
| return |
| } |
| log.Printf("sshd started.") |
| waitLocalSSH() |
| } |
| |
| // waitLocalSSH waits for sshd to start accepting connections. |
| func waitLocalSSH() { |
| for i := 0; i < 40; i++ { |
| time.Sleep(10 * time.Millisecond * time.Duration(i+1)) |
| c, err := net.Dial("tcp", "localhost:"+sshPort()) |
| if err == nil { |
| c.Close() |
| log.Printf("sshd connected.") |
| return |
| } |
| } |
| log.Printf("timeout waiting for sshd to come up") |
| } |
| |
| func numProcs() int { |
| n := 0 |
| fis, _ := ioutil.ReadDir("/proc") |
| for _, fi := range fis { |
| if _, err := strconv.Atoi(fi.Name()); err == nil { |
| n++ |
| } |
| } |
| return n |
| } |
| |
| func fileSHA1(path string) (string, error) { |
| f, err := os.Open(path) |
| if err != nil { |
| return "", err |
| } |
| defer f.Close() |
| s1 := sha1.New() |
| if _, err := io.Copy(s1, f); err != nil { |
| return "", err |
| } |
| return fmt.Sprintf("%x", s1.Sum(nil)), nil |
| } |
| |
| func validRelPath(p string) bool { |
| if p == "" || strings.Contains(p, `\`) || strings.HasPrefix(p, "/") || strings.Contains(p, "../") { |
| return false |
| } |
| return true |
| } |
| |
| type httpStatuser interface { |
| error |
| httpStatus() int |
| } |
| |
| type httpError struct { |
| statusCode int |
| msg string |
| } |
| |
| func (he httpError) Error() string { return he.msg } |
| func (he httpError) httpStatus() int { return he.statusCode } |
| |
| func badRequest(msg string) error { |
| return httpError{http.StatusBadRequest, msg} |
| } |
| |
| // requirePassword is an http.Handler auth wrapper that enforces a |
| // HTTP Basic password. The username is ignored. |
| type requirePasswordHandler struct { |
| h http.Handler |
| password string // empty means no password |
| } |
| |
| func (h requirePasswordHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| _, gotPass, _ := r.BasicAuth() |
| if h.password != "" && h.password != gotPass { |
| http.Error(w, "invalid password", http.StatusForbidden) |
| return |
| } |
| h.h.ServeHTTP(w, r) |
| } |
| |
| // plan9LogWriter truncates log writes to 128 bytes, |
| // to work around some Plan 9 and/or GCE serial port bug. |
| type plan9LogWriter struct { |
| w io.Writer |
| buf []byte |
| } |
| |
| func (pw *plan9LogWriter) Write(p []byte) (n int, err error) { |
| const max = 128 - len("\n\x00") |
| if len(p) < max { |
| return pw.w.Write(p) |
| } |
| if pw.buf == nil { |
| pw.buf = make([]byte, max+1) |
| } |
| n = copy(pw.buf[:max], p) |
| pw.buf[n] = '\n' |
| return pw.w.Write(pw.buf[:n+1]) |
| } |
| |
| var killProcessTree = killProcessTreeUnix |
| |
| func killProcessTreeUnix(p *os.Process) error { |
| return p.Kill() |
| } |
| |
| // configureMacStadium configures the buildlet flags for use on a Mac |
| // VM running on MacStadium under VMWare. |
| func configureMacStadium() { |
| *haltEntireOS = true |
| |
| // TODO: setup RAM disk for tmp and set *workDir |
| |
| disableMacScreensaver() |
| enableMacDeveloperMode() |
| |
| version, err := exec.Command("sw_vers", "-productVersion").Output() |
| if err != nil { |
| log.Fatalf("failed to find sw_vers -productVersion: %v", err) |
| } |
| majorMinor := regexp.MustCompile(`^(\d+)\.(\d+)`) |
| m := majorMinor.FindStringSubmatch(string(version)) |
| if m == nil { |
| log.Fatalf("unsupported sw_vers version %q", version) |
| } |
| major, minor := m[1], m[2] // "10", "12" |
| *reverseType = fmt.Sprintf("host-darwin-%s_%s", major, minor) |
| *coordinator = "farmer.golang.org:443" |
| |
| // guestName is set by cmd/makemac to something like |
| // "mac_10_10_host01b" or "mac_10_12_host01a", which encodes |
| // three things: the mac OS version of the guest VM, which |
| // physical machine it's on (1 to 10, currently) and which of |
| // two possible VMs on that host is running (a or b). For |
| // monitoring purposes, we want stable hostnames and don't |
| // care which OS version is currently running (which changes |
| // constantly), so normalize these to only have the host |
| // number and side (a or b), without the OS version. The |
| // buildlet will report the OS version to the coordinator |
| // anyway. We could in theory do this normalization in the |
| // coordinator, but we don't want to put buildlet-specific |
| // knowledge there, and this file already contains a bunch of |
| // buildlet host-specific configuration, so normalize it here. |
| guestName := vmwareGetInfo("guestinfo.name") // "mac_10_12_host01a" |
| hostPos := strings.Index(guestName, "_host") |
| if hostPos == -1 { |
| // Assume cmd/makemac changed its conventions. |
| // Maybe all this normalization belongs there anyway, |
| // but normalizing here is a safer first step. |
| *hostname = guestName |
| } else { |
| *hostname = "macstadium" + guestName[hostPos:] // "macstadium_host01a" |
| } |
| } |
| |
| func disableMacScreensaver() { |
| err := exec.Command("defaults", "-currentHost", "write", "com.apple.screensaver", "idleTime", "0").Run() |
| if err != nil { |
| log.Printf("disabling screensaver: %v", err) |
| } |
| } |
| |
| // enableMacDeveloperMode enables developer mode on macOS for the |
| // runtime tests. (Issue 31123) |
| // |
| // It is best effort; errors are logged but otherwise ignored. |
| func enableMacDeveloperMode() { |
| // Macs are configured with password-less sudo. Without sudo we get prompts |
| // that "SampleTools wants to make changes" that block the buildlet from starting. |
| // But oddly, not via gomote. Only during startup. The environment must be different |
| // enough that in one case macOS asks for permission (because it can use the GUI?) |
| // and in the gomote case (where the environment is largley scrubbed) it can't do |
| // the GUI dialog somehow and must just try to do it anyway and finds that passwordless |
| // sudo works. But using sudo seems to make it always work. |
| // For extra paranoia, use a context to not block start-up. |
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| defer cancel() |
| |
| out, err := exec.CommandContext(ctx, "/usr/bin/sudo", "/usr/sbin/DevToolsSecurity", "-enable").CombinedOutput() |
| if err != nil { |
| log.Printf("Error enabling developer mode: %v, %s", err, out) |
| return |
| } |
| log.Printf("DevToolsSecurity: %s", out) |
| } |
| |
| func vmwareGetInfo(key string) string { |
| cmd := exec.Command("/Library/Application Support/VMware Tools/vmware-tools-daemon", |
| "--cmd", |
| "info-get "+key) |
| var stdout, stderr bytes.Buffer |
| cmd.Stdout = &stdout |
| cmd.Stderr = &stderr |
| err := cmd.Run() |
| if err != nil { |
| if strings.Contains(stderr.String(), "No value found") { |
| return "" |
| } |
| log.Fatalf("Error running vmware-tools-daemon --cmd 'info-get %s': %v, %s\n%s", key, err, stderr.Bytes(), stdout.Bytes()) |
| } |
| return strings.TrimSpace(stdout.String()) |
| } |
| |
| func makeBSDFilesystemFast() { |
| if !metadata.OnGCE() { |
| log.Printf("Not on GCE; not remounting root filesystem.") |
| return |
| } |
| btype, err := metadata.InstanceAttributeValue("buildlet-host-type") |
| if _, ok := err.(metadata.NotDefinedError); ok && len(btype) == 0 { |
| log.Printf("Not remounting root filesystem due to missing buildlet-host-type metadata.") |
| return |
| } |
| if err != nil { |
| log.Printf("Not remounting root filesystem due to failure getting builder type instance metadata: %v", err) |
| return |
| } |
| // Tested on OpenBSD, FreeBSD, and NetBSD: |
| out, err := exec.Command("/sbin/mount", "-u", "-o", "async,noatime", "/").CombinedOutput() |
| if err != nil { |
| log.Printf("Warning: failed to remount %s root filesystem with async,noatime: %v, %s", runtime.GOOS, err, out) |
| return |
| } |
| log.Printf("Remounted / with async,noatime.") |
| } |
| |
| func appendSSHAuthorizedKey(sshUser, authKey string) error { |
| var homeRoot string |
| switch runtime.GOOS { |
| case "darwin": |
| homeRoot = "/Users" |
| case "plan9": |
| return fmt.Errorf("ssh not supported on %v", runtime.GOOS) |
| case "windows": |
| homeRoot = `C:\Users` |
| default: |
| homeRoot = "/home" |
| if runtime.GOOS == "freebsd" { |
| if fi, err := os.Stat("/usr/home/" + sshUser); err == nil && fi.IsDir() { |
| homeRoot = "/usr/home" |
| } |
| } |
| if sshUser == "root" { |
| homeRoot = "/" |
| } |
| } |
| sshDir := filepath.Join(homeRoot, sshUser, ".ssh") |
| if err := os.MkdirAll(sshDir, 0700); err != nil { |
| return err |
| } |
| if err := os.Chmod(sshDir, 0700); err != nil { |
| return err |
| } |
| authFile := filepath.Join(sshDir, "authorized_keys") |
| exist, err := ioutil.ReadFile(authFile) |
| if err != nil && !os.IsNotExist(err) { |
| return err |
| } |
| if strings.Contains(string(exist), authKey) { |
| return nil |
| } |
| f, err := os.OpenFile(authFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) |
| if err != nil { |
| return err |
| } |
| if _, err := fmt.Fprintf(f, "%s\n", authKey); err != nil { |
| f.Close() |
| return err |
| } |
| if err := f.Close(); err != nil { |
| return err |
| } |
| if runtime.GOOS == "freebsd" { |
| exec.Command("/usr/sbin/chown", "-R", sshUser, sshDir).Run() |
| } |
| if runtime.GOOS == "windows" { |
| if res, err := exec.Command("icacls.exe", authFile, "/grant", `NT SERVICE\sshd:(R)`).CombinedOutput(); err != nil { |
| return fmt.Errorf("setting permissions on authorized_keys with: %v\n%s", err, res) |
| } |
| } |
| return nil |
| } |
| |
| // setWorkdirToTmpfs sets the *workDir (--workdir) flag to /workdir |
| // if the flag is empty and /workdir is a tmpfs mount, as it is on the various |
| // hosts that use rundockerbuildlet. |
| // |
| // It is set non-nil on operating systems where the functionality is |
| // needed & available. Currently we only use it on Linux. |
| var setWorkdirToTmpfs func() |
| |
| func initBaseUnixEnv() { |
| if os.Getenv("USER") == "" { |
| os.Setenv("USER", "root") |
| } |
| if os.Getenv("HOME") == "" { |
| os.Setenv("HOME", "/root") |
| } |
| } |
| |
| // removeAllAndMkdir calls removeAllIncludingReadonly and then os.Mkdir on the given |
| // dir, failing the process if either step fails. |
| func removeAllAndMkdir(dir string) { |
| if err := removeAllIncludingReadonly(dir); err != nil { |
| log.Fatal(err) |
| } |
| if err := os.Mkdir(dir, 0755); err != nil { |
| log.Fatal(err) |
| } |
| } |
| |
| // removeAllIncludingReadonly is like os.RemoveAll except that it'll |
| // also try to change permissions to work around permission errors |
| // when deleting. |
| func removeAllIncludingReadonly(dir string) error { |
| err := os.RemoveAll(dir) |
| if err == nil || !os.IsPermission(err) || |
| runtime.GOOS == "windows" { // different filesystem permission model; also our windows builders are ephemeral single-use VMs anyway |
| return err |
| } |
| // Make a best effort (ignoring errors) attempt to make all |
| // files and directories writable before we try to delete them |
| // all again. |
| filepath.Walk(dir, func(path string, fi os.FileInfo, err error) error { |
| const ownerWritable = 0200 |
| if err != nil || fi.Mode().Perm()&ownerWritable != 0 { |
| return nil |
| } |
| os.Chmod(path, fi.Mode().Perm()|ownerWritable) |
| return nil |
| }) |
| return os.RemoveAll(dir) |
| } |
| |
| var ( |
| androidEmuDead = make(chan error) // closed on death |
| androidEmuErr error // set prior to channel close |
| ) |
| |
| func startAndroidEmulator() { |
| cmd := exec.Command("/android/sdk/emulator/emulator", |
| "@android-avd", |
| "-no-audio", |
| "-no-window", |
| "-no-boot-anim", |
| "-no-snapshot-save", |
| "-wipe-data", // required to prevent a hang with -no-window when recovering from a snapshot? |
| ) |
| log.Printf("running Android emulator: %v", cmd.Args) |
| cmd.Stdout = os.Stdout |
| cmd.Stderr = os.Stderr |
| if err := cmd.Start(); err != nil { |
| log.Fatalf("failed to start Android emulator: %v", err) |
| } |
| go func() { |
| err := cmd.Wait() |
| if err == nil { |
| err = errors.New("exited without error") |
| } |
| androidEmuErr = err |
| close(androidEmuDead) |
| }() |
| } |
| |
| // checkAndroidEmulator returns an error if this machine is an Android builder |
| // and the Android emulator process has exited. |
| func checkAndroidEmulator() error { |
| select { |
| case <-androidEmuDead: |
| return androidEmuErr |
| default: |
| return nil |
| } |
| } |
| |
| var disableNetOnce sync.Once |
| |
| func disableOutboundNetwork() { |
| if runtime.GOOS != "linux" { |
| return |
| } |
| disableNetOnce.Do(disableOutboundNetworkLinux) |
| } |
| |
| func disableOutboundNetworkLinux() { |
| const iptables = "/sbin/iptables" |
| const vcsTestGolangOrgIP = "35.184.38.56" // vcs-test.golang.org |
| runOrLog(exec.Command(iptables, "-I", "OUTPUT", "1", "-m", "state", "--state", "NEW", "-d", vcsTestGolangOrgIP, "-p", "tcp", "-j", "ACCEPT")) |
| runOrLog(exec.Command(iptables, "-I", "OUTPUT", "2", "-m", "state", "--state", "NEW", "-d", "10.0.0.0/8", "-p", "tcp", "-j", "ACCEPT")) |
| runOrLog(exec.Command(iptables, "-I", "OUTPUT", "3", "-m", "state", "--state", "NEW", "-p", "tcp", "--dport", "443", "-j", "REJECT", "--reject-with", "icmp-host-prohibited")) |
| runOrLog(exec.Command(iptables, "-I", "OUTPUT", "3", "-m", "state", "--state", "NEW", "-p", "tcp", "--dport", "22", "-j", "REJECT", "--reject-with", "icmp-host-prohibited")) |
| } |
| |
| func runOrLog(cmd *exec.Cmd) { |
| out, err := cmd.CombinedOutput() |
| if err != nil { |
| log.Printf("failed to run %s: %v, %s", cmd.Args, err, out) |
| } |
| } |