package server import ( "context" "errors" "fmt" "net" "os" "os/exec" "os/user" "strings" "time" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/server" "coopcloud.tech/abra/pkg/ssh" "github.com/AlecAivazis/survey/v2" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/swarm" dockerClient "github.com/docker/docker/client" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) var local bool var localFlag = &cli.BoolFlag{ Name: "local", Aliases: []string{"l"}, Value: false, Usage: "Use local server", Destination: &local, } var provision bool var provisionFlag = &cli.BoolFlag{ Name: "provision", Aliases: []string{"p"}, Value: false, Usage: "Provision server so it can deploy apps", Destination: &provision, } var sshAuth string var sshAuthFlag = &cli.StringFlag{ Name: "ssh-auth", Aliases: []string{"sh"}, Value: "identity-file", Usage: "Select SSH authentication method (identity-file, password)", Destination: &sshAuth, } var askSudoPass bool var askSudoPassFlag = &cli.BoolFlag{ Name: "ask-sudo-pass", Aliases: []string{"as"}, Value: false, Usage: "Ask for sudo password", Destination: &askSudoPass, } var traefik bool var traefikFlag = &cli.BoolFlag{ Name: "traefik", Aliases: []string{"t"}, Value: false, Usage: "Deploy traefik", Destination: &traefik, } func newLocalServer(c *cli.Context, domainName string) error { if err := createServerDir(domainName); err != nil { return err } cl, err := newClient(c, domainName) if err != nil { return err } if _, err := exec.LookPath("docker"); err != nil { if provision { if err := installDocker(c, cl, domainName); err != nil { return err } if err := initSwarm(c, cl, domainName); err != nil { logrus.Fatal(err) } } else { logrus.Warn("no docker installation found, use '-p' to provision") } } if traefik { if err := deployTraefik(c, cl, domainName); err != nil { return err } } else { logrus.Warn("no traefik app found, use '-t' to deploy") } logrus.Info("local server has been added") return nil } func newContext(c *cli.Context, domainName string) error { username := c.Args().Get(1) if username == "" { systemUser, err := user.Current() if err != nil { return err } username = systemUser.Username } port := c.Args().Get(2) if port == "" { port = "22" } store := client.NewDefaultDockerContextStore() contexts, err := store.Store.List() if err != nil { return err } for _, context := range contexts { if context.Name == domainName { logrus.Debugf("context for %s already exists", domainName) return nil } } logrus.Debugf("creating context with domain %s, username %s and port %s", domainName, username, port) if err := client.CreateContext(domainName, username, port); err != nil { return err } return nil } func newClient(c *cli.Context, domainName string) (*dockerClient.Client, error) { cl, err := client.New(domainName) if err != nil { logrus.Warn("cleaning up context due to connection failure") if err := client.DeleteContext(domainName); err != nil { return &dockerClient.Client{}, err } return &dockerClient.Client{}, err } return cl, nil } func installDocker(c *cli.Context, cl *dockerClient.Client, domainName string) error { logrus.Debugf("attempting to construct SSH client for %s", domainName) client, err := ssh.New(domainName, sshAuth) if err != nil { return err } defer client.Close() logrus.Debugf("successfully created SSH client for %s", domainName) result, err := client.Exec("which docker") if err != nil && string(result) != "" { return err } if string(result) == "" { fmt.Println(fmt.Sprintf(` A docker installation cannot be found on %s. This is a required system dependency for running Co-op Cloud on your server. If you would like, Abra can attempt to install Docker for you using the upstream non-interactive installation script. See the following documentation for more: https://docs.docker.com/engine/install/debian/#install-using-the-convenience-script N.B Docker doesn't recommend it for production environments but many use it for such purposes. Docker stable is now installed by default by this script. The source for this script can be seen here: https://github.com/docker/docker-install `, domainName)) response := false prompt := &survey.Confirm{ Message: fmt.Sprintf("attempt install docker on %s?", domainName), } if err := survey.AskOne(prompt, &response); err != nil { return err } if !response { logrus.Fatal("exiting as requested") } cmd := "curl -s https://get.docker.com | bash" var sudoPass string if askSudoPass { prompt := &survey.Password{ Message: "sudo password?", } if err := survey.AskOne(prompt, &sudoPass); err != nil { return err } logrus.Debugf("running '%s' on %s now with sudo password", cmd, domainName) _, err := client.ExecSudo(cmd, sudoPass) if err != nil { return err } } else { logrus.Debugf("running '%s' on %s now without sudo password", cmd, domainName) _, err := client.Exec(cmd) if err != nil { return err } } } logrus.Infof("docker is installed on %s", domainName) return nil } func initSwarm(c *cli.Context, cl *dockerClient.Client, domainName string) error { // comrade librehosters DNS resolver -> https://www.privacy-handbuch.de/handbuch_93d.htm freifunkDNS := "5.1.66.255:53" resolver := &net.Resolver{ PreferGo: false, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { d := net.Dialer{ Timeout: time.Millisecond * time.Duration(10000), } return d.DialContext(ctx, "udp", freifunkDNS) }, } logrus.Debugf("created DNS resolver via '%s'", freifunkDNS) ips, err := resolver.LookupIPAddr(c.Context, domainName) if err != nil { return err } if len(ips) == 0 { return fmt.Errorf("unable to retrieve ipv4 address for %s", domainName) } ipv4 := ips[0].IP.To4().String() logrus.Debugf("discovered the following ipv4 addr: %s", ipv4) initReq := swarm.InitRequest{ ListenAddr: "0.0.0.0:2377", AdvertiseAddr: ipv4, } if _, err := cl.SwarmInit(c.Context, initReq); err != nil { if !strings.Contains(err.Error(), "is already part of a swarm") { return err } } logrus.Infof("initialised swarm mode on %s", domainName) netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"} if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil { return err } logrus.Infof("swarm overlay network created on %s", domainName) return nil } func createServerDir(domainName string) error { if err := server.CreateServerDir(domainName); err != nil { if !os.IsExist(err) { return err } logrus.Debugf("server dir for %s already created", domainName) } return nil } func deployTraefik(c *cli.Context, cl *dockerClient.Client, domainName string) error { // TODO: implement logrus.Warn("NOT IMPLEMENTED - COMING SOON") return nil } var serverAddCommand = &cli.Command{ Name: "add", Usage: "Add a server to your configuration", Description: ` This command adds a new server to your configuration so that it can be managed by Abra. This can be useful when you already have a server provisioned and want to start running Abra commands against it. This command can also provision your server ("--provision/-p") so that it is capable of hosting Co-op Cloud apps. Abra will default to expecting that you have a working SSH config for the host in your ~/.ssh/config file. E.g. for "example.com", you'll want to have something like: Host example.com Hostname 192.168.178.31 # domain name also works User myuserontheserver Port 12345 IdentityFile ~/.ssh/mysecretkey.local If you have no SSH key configured for this host and are instead using password authentication, you may pass "--ssh-auth password" to have Abra ask you for the password. "--ask-sudo-pass" may be passed if you run your provisioning commands via sudo privilege escalation. If "--local" is passed, then Abra assumes that the current local server is intended as the target server. This is useful when you want to have your entire Co-op Cloud config located on the server itself, and not on your local developer machine. Otherwise, you may specify a remote server. The argument must be a publicy accessible domain name which points to your server. You should have SSH access to this server, Abra will assume port 22 and will use your current system username to make an initial connection. You can use the and arguments to adjust this. Example: abra server add --provision --traefik varia.zone glodemodem 12345 Abra will construct the following SSH connection string then: ssh://globemodem@varia.zone:12345 All communication between Abra and the server will use this SSH connection. In this example, Abra will run the following operations: 1. Install Docker 2. Initialise Swarm mode 3. Deploy Traefik (core web proxy) You may omit flags to avoid performing this provisioning logic. `, Aliases: []string{"a"}, Flags: []cli.Flag{ localFlag, provisionFlag, sshAuthFlag, askSudoPassFlag, traefikFlag, }, ArgsUsage: " [] []", Action: func(c *cli.Context) error { if c.Args().Len() > 0 && local { err := errors.New("cannot use '' and '--local' together") internal.ShowSubcommandHelpAndError(c, err) } if sshAuth != "password" && sshAuth != "identity-file" { err := errors.New("--ssh-auth only accepts 'identity-file' or 'password'") internal.ShowSubcommandHelpAndError(c, err) } if local { if err := newLocalServer(c, "default"); err != nil { logrus.Fatal(err) } return nil } domainName, err := internal.ValidateDomain(c) if err != nil { logrus.Fatal(err) } if err := createServerDir(domainName); err != nil { logrus.Fatal(err) } if err := newContext(c, domainName); err != nil { logrus.Fatal(err) } cl, err := newClient(c, domainName) if err != nil { logrus.Fatal(err) } if provision { if err := installDocker(c, cl, domainName); err != nil { logrus.Fatal(err) } if err := initSwarm(c, cl, domainName); err != nil { logrus.Fatal(err) } } if traefik { if err := deployTraefik(c, cl, domainName); err != nil { logrus.Fatal(err) } } return nil }, }