// Copyright (c) 2021 Gitpod GmbH. All rights reserved. // Licensed under the GNU Affero General Public License (AGPL). // See License.AGPL.txt in the project root for license information. package main import ( "path/filepath" "strconv" "time" "context" "net/http" "net/url" "os" "regexp" "strings" gitpod "github.com/gitpod-io/gitpod/gitpod-protocol" appapi "github.com/gitpod-io/gitpod/local-app/api" "github.com/gitpod-io/local-app/pkg/auth" "github.com/gitpod-io/local-app/pkg/bastion" "github.com/improbable-eng/grpc-web/go/grpcweb" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" "github.com/zalando/go-keyring" "google.golang.org/grpc" ) var ( // Version - set during build Version = "dev" ) func main() { sshConfig := os.Getenv("GITPOD_LCA_SSH_CONFIG") if sshConfig == "" { sshConfig = filepath.Join(os.TempDir(), "gitpod_ssh_config") } app := cli.App{ Name: "gitpod-local-companion", Usage: "connect your Gitpod workspaces", Action: DefaultCommand("run"), EnableBashCompletion: true, Version: Version, Flags: []cli.Flag{ &cli.StringFlag{ Name: "gitpod-host", Usage: "URL of the Gitpod installation to connect to", EnvVars: []string{ "GITPOD_HOST", }, Value: "https://gitpod.io", }, &cli.BoolFlag{ Name: "mock-keyring", Usage: "Don't use system native keyring, but store Gitpod token in memory", }, &cli.BoolFlag{ Name: "allow-cors-from-port", Usage: "Allow CORS requests from workspace port location", }, &cli.IntFlag{ Name: "api-port", Usage: "Local App API endpoint's port", EnvVars: []string{ "GITPOD_LCA_API_PORT", }, Value: 63100, }, &cli.BoolFlag{ Name: "auto-tunnel", Usage: "Enable auto tunneling", EnvVars: []string{ "GITPOD_LCA_AUTO_TUNNEL", }, Value: true, }, &cli.StringFlag{ Name: "auth-redirect-url", EnvVars: []string{ "GITPOD_LCA_AUTH_REDIRECT_URL", }, }, &cli.BoolFlag{ Name: "verbose", Usage: "Enable verbose logging", EnvVars: []string{ "GITPOD_LCA_VERBOSE", }, Value: false, }, &cli.DurationFlag{ Name: "auth-timeout", Usage: "Auth timeout in seconds", EnvVars: []string{ "GITPOD_LCA_AUTH_TIMEOUT", }, Value: 30, }, &cli.StringFlag{ Name: "timeout", Usage: "How long the local app can run if last workspace was stopped", EnvVars: []string{ "GITPOD_LCA_TIMEOUT", }, Value: "0", }, }, Commands: []*cli.Command{ { Name: "run", Action: func(c *cli.Context) error { if c.Bool("mock-keyring") { keyring.MockInit() } return run(runOptions{ origin: c.String("gitpod-host"), sshConfigPath: c.String("ssh_config"), apiPort: c.Int("api-port"), allowCORSFromPort: c.Bool("allow-cors-from-port"), autoTunnel: c.Bool("auto-tunnel"), authRedirectURL: c.String("auth-redirect-url"), verbose: c.Bool("verbose"), authTimeout: c.Duration("auth-timeout"), localAppTimeout: c.Duration("timeout"), }) }, Flags: []cli.Flag{ &cli.PathFlag{ Name: "ssh_config", Usage: "produce and update an OpenSSH compatible ssh_config file (defaults to $GITPOD_LCA_SSH_CONFIG)", Value: sshConfig, }, }, }, }, } err := app.Run(os.Args) if err != nil { logrus.WithError(err).Fatal("Failed to start application.") } } func DefaultCommand(name string) cli.ActionFunc { return func(ctx *cli.Context) error { return ctx.App.Command(name).Run(ctx) } } type runOptions struct { origin string sshConfigPath string apiPort int allowCORSFromPort bool autoTunnel bool authRedirectURL string verbose bool authTimeout time.Duration localAppTimeout time.Duration } func run(opts runOptions) error { if opts.verbose { logrus.SetLevel(logrus.DebugLevel) } logrus.WithField("ssh_config", opts.sshConfigPath).Info("writing workspace ssh_config file") // Trailing slash(es) result in connection issues, so remove them preemptively origin := strings.TrimRight(opts.origin, "/") originURL, err := url.Parse(origin) if err != nil { return err } wsHostRegex := "(\\.[^.]+)\\." + strings.ReplaceAll(originURL.Host, ".", "\\.") wsHostRegex = "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-z]{2,16}-[0-9a-z]{2,16}-[0-9a-z]{8,11})" + wsHostRegex if opts.allowCORSFromPort { wsHostRegex = "([0-9]+)-" + wsHostRegex } hostRegex, err := regexp.Compile("^" + wsHostRegex) if err != nil { return err } var b *bastion.Bastion client, err := connectToServer(auth.LoginOpts{GitpodURL: origin, RedirectURL: opts.authRedirectURL, AuthTimeout: opts.authTimeout}, func() { if b != nil { b.FullUpdate() } }, func(closeErr error) { logrus.WithError(closeErr).Error("server connection failed") os.Exit(1) }) if err != nil { return err } cb := bastion.CompositeCallbacks{ &logCallbacks{}, } s := &bastion.SSHConfigWritingCallback{Path: opts.sshConfigPath} if opts.sshConfigPath != "" { cb = append(cb, s) } b = bastion.New(client, opts.localAppTimeout, cb) b.EnableAutoTunnel = opts.autoTunnel grpcServer := grpc.NewServer() appapi.RegisterLocalAppServer(grpcServer, bastion.NewLocalAppService(b, s)) allowOrigin := func(origin string) bool { // Is the origin a subdomain of the installations hostname? return hostRegex.Match([]byte(origin)) } go func() { err := http.ListenAndServe("localhost:"+strconv.Itoa(opts.apiPort), grpcweb.WrapServer(grpcServer, grpcweb.WithCorsForRegisteredEndpointsOnly(false), grpcweb.WithOriginFunc(allowOrigin), grpcweb.WithWebsockets(true), grpcweb.WithWebsocketOriginFunc(func(req *http.Request) bool { origin, err := grpcweb.WebsocketRequestOrigin(req) if err != nil { return false } return allowOrigin(origin) }), grpcweb.WithWebsocketPingInterval(15*time.Second), )) if err != nil { logrus.WithError(err).Error("API endpoint failed to start") os.Exit(1) } }() defer grpcServer.Stop() return b.Run() } func connectToServer(loginOpts auth.LoginOpts, reconnectionHandler func(), closeHandler func(error)) (*gitpod.APIoverJSONRPC, error) { var client *gitpod.APIoverJSONRPC onClose := func(closeErr error) { if client != nil { closeHandler(closeErr) } } tkn, err := auth.GetToken(loginOpts.GitpodURL) if err != nil { return nil, err } if tkn != "" { // try to connect with existing token client, err = tryConnectToServer(loginOpts.GitpodURL, tkn, reconnectionHandler, onClose) if client != nil { return client, err } _, invalid := err.(*auth.ErrInvalidGitpodToken) if !invalid { return nil, err } // existing token is invalid, try again logrus.WithError(err).WithField("origin", loginOpts.GitpodURL).Error() } tkn, err = login(loginOpts) if err != nil { return nil, err } client, err = tryConnectToServer(loginOpts.GitpodURL, tkn, reconnectionHandler, onClose) return client, err } func tryConnectToServer(gitpodUrl string, tkn string, reconnectionHandler func(), closeHandler func(error)) (*gitpod.APIoverJSONRPC, error) { wshost := gitpodUrl wshost = strings.ReplaceAll(wshost, "https://", "wss://") wshost = strings.ReplaceAll(wshost, "http://", "ws://") wshost += "/api/v1" client, err := gitpod.ConnectToServer(wshost, gitpod.ConnectToServerOpts{ Context: context.Background(), Token: tkn, Log: logrus.NewEntry(logrus.StandardLogger()), ReconnectionHandler: reconnectionHandler, CloseHandler: closeHandler, ExtraHeaders: map[string]string{ "User-Agent": "gitpod/local-companion", "X-Client-Version": Version, }, }) if err != nil { return nil, err } err = auth.ValidateToken(client, tkn) if err == nil { return client, nil } closeErr := client.Close() if closeErr != nil { logrus.WithError(closeErr).WithField("origin", gitpodUrl).Warn("failed to close connection to gitpod server") } deleteErr := auth.DeleteToken(gitpodUrl) if deleteErr != nil { logrus.WithError(deleteErr).WithField("origin", gitpodUrl).Warn("failed to delete gitpod token") } return nil, err } func login(loginOpts auth.LoginOpts) (string, error) { tkn, err := auth.Login(context.Background(), loginOpts) if tkn != "" { err = auth.SetToken(loginOpts.GitpodURL, tkn) if err != nil { logrus.WithField("origin", loginOpts.GitpodURL).Warnf("could not write token to keyring: %s", err) // Allow to continue err = nil } } return tkn, err } type logCallbacks struct{} func (*logCallbacks) InstanceUpdate(w *bastion.Workspace) { logrus.WithField("workspace", w).Info("Instance update") }