diff --git a/api/server_endpoints.go b/api/server_endpoints.go index da33018..7ecad84 100644 --- a/api/server_endpoints.go +++ b/api/server_endpoints.go @@ -41,6 +41,14 @@ type ProcessConfiguration struct { ConfigurationFiles []parser.ConfigurationFile `json:"configs"` } +// Defines installation script information for a server process. This is used when +// a server is installed for the first time, and when a server is marked for re-installation. +type InstallationScript struct { + ContainerImage string `json:"container_image"` + Entrypoint string `json:"entrypoint"` + Script string `json:"script"` +} + // Fetches the server configuration and returns the struct for it. func (r *PanelRequest) GetServerConfiguration(uuid string) (*ServerConfigurationResponse, *RequestError, error) { resp, err := r.Get(fmt.Sprintf("/servers/%s", uuid)) @@ -64,3 +72,28 @@ func (r *PanelRequest) GetServerConfiguration(uuid string) (*ServerConfiguration return res, nil, nil } + +// Fetches installation information for the server process. +func (r *PanelRequest) GetInstallationScript(uuid string) (InstallationScript, *RequestError, error) { + res := InstallationScript{} + + resp, err := r.Get(fmt.Sprintf("/servers/%s/install", uuid)) + if err != nil { + return res, nil, errors.WithStack(err) + } + defer resp.Body.Close() + + r.Response = resp + + if r.HasError() { + return res, r.Error(), nil + } + + b, _ := r.ReadBody() + + if err := json.Unmarshal(b, &res); err != nil { + return res, nil, errors.WithStack(err) + } + + return res, nil, nil +} diff --git a/http.go b/http.go index 66d0878..cab5474 100644 --- a/http.go +++ b/http.go @@ -420,6 +420,20 @@ func (rt *Router) routeServerSendCommand(w http.ResponseWriter, r *http.Request, w.WriteHeader(http.StatusNoContent) } +func (rt *Router) routeServerInstall(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + s := rt.GetServer(ps.ByName("server")) + defer r.Body.Close() + + if err := s.Install(); err != nil { + zap.S().Errorw("failed to install server", zap.String("server", s.Uuid), zap.Error(err)) + + http.Error(w, "failed to install server", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (rt *Router) routeServerUpdate(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { s := rt.GetServer(ps.ByName("server")) defer r.Body.Close() @@ -545,7 +559,8 @@ func (rt *Router) ConfigureRouter() *httprouter.Router { router.GET("/api/servers/:server/files/contents", rt.AuthenticateRequest(rt.routeServerFileRead)) router.GET("/api/servers/:server/files/list-directory", rt.AuthenticateRequest(rt.routeServerListDirectory)) router.PUT("/api/servers/:server/files/rename", rt.AuthenticateRequest(rt.routeServerRenameFile)) - router.POST("/api/servers", rt.AuthenticateToken(rt.routeCreateServer)); + router.POST("/api/servers", rt.AuthenticateToken(rt.routeCreateServer)) + router.POST("/api/servers/:server/install", rt.AuthenticateRequest(rt.routeServerInstall)) router.POST("/api/servers/:server/files/copy", rt.AuthenticateRequest(rt.routeServerCopyFile)) router.POST("/api/servers/:server/files/write", rt.AuthenticateRequest(rt.routeServerWriteFile)) router.POST("/api/servers/:server/files/create-directory", rt.AuthenticateRequest(rt.routeServerCreateDirectory)) diff --git a/server/environment_docker.go b/server/environment_docker.go index 6dd8d50..2763804 100644 --- a/server/environment_docker.go +++ b/server/environment_docker.go @@ -545,6 +545,7 @@ func (d *DockerEnvironment) Create() error { Labels: map[string]string{ "Service": "Pterodactyl", + "ContainerType": "server_process", }, } diff --git a/server/install.go b/server/install.go new file mode 100644 index 0000000..8ec9fea --- /dev/null +++ b/server/install.go @@ -0,0 +1,263 @@ +package server + +import ( + "bufio" + "bytes" + "context" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/client" + "github.com/pkg/errors" + "github.com/pterodactyl/wings/api" + "go.uber.org/zap" + "io" + "io/ioutil" + "os" + "path/filepath" + "sync" +) + +func (s *Server) Install() error { + script, rerr, err := api.NewRequester().GetInstallationScript(s.Uuid) + if err != nil || rerr != nil { + if err != nil { + return err + } + + return errors.New(rerr.String()) + } + + p, err := NewInstallationProcess(s, &script) + if err != nil { + return errors.WithStack(err) + } + + return p.Execute() +} + +type InstallationProcess struct { + Server *Server + Script *api.InstallationScript + + client *client.Client + mutex *sync.Mutex +} + +// Generates a new installation process struct that will be used to create containers, +// and otherwise perform installation commands for a server. +func NewInstallationProcess(s *Server, script *api.InstallationScript) (*InstallationProcess, error) { + proc := &InstallationProcess{ + Script: script, + Server: s, + mutex: &sync.Mutex{}, + } + + if c, err := client.NewClientWithOpts(client.FromEnv); err != nil { + return nil, errors.WithStack(err) + } else { + proc.client = c + } + + return proc, nil +} + +// Writes the installation script to a temporary file on the host machine so that it +// can be properly mounted into the installation container and then executed. +func (ip *InstallationProcess) writeScriptToDisk() (string, error) { + d, err := ioutil.TempDir("", "pterodactyl") + if err != nil { + return "", errors.WithStack(err) + } + + f, err := os.OpenFile(filepath.Join(d, "install.sh"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return "", errors.WithStack(err) + } + defer f.Close() + + w := bufio.NewWriter(f) + + scanner := bufio.NewScanner(bytes.NewReader([]byte(ip.Script.Script))) + for scanner.Scan() { + w.WriteString(scanner.Text()+"\n") + } + + if err := scanner.Err(); err != nil { + return "", errors.WithStack(err) + } + + w.Flush() + + return d, nil +} + +// Pulls the docker image to be used for the installation container. +func (ip *InstallationProcess) pullInstallationImage() error { + r, err := ip.client.ImagePull(context.Background(), ip.Script.ContainerImage, types.ImagePullOptions{}) + if err != nil { + return errors.WithStack(err) + } + + // Copy to stdout until we hit an EOF or other fatal error which would + // require exiting. + if _, err := io.Copy(os.Stdout, r); err != nil && err != io.EOF { + return errors.WithStack(err) + } + + return nil +} + +// Runs before the container is executed. This pulls down the required docker container image +// as well as writes the installation script to the disk. This process is executed in an async +// manner, if either one fails the error is returned. +func (ip *InstallationProcess) beforeExecute() (string, error) { + wg := sync.WaitGroup{} + wg.Add(3) + + var e []error + var fileName string + + go func() { + defer wg.Done() + name, err := ip.writeScriptToDisk() + if err != nil { + e = append(e, err) + return + } + + fileName = name + }() + + go func() { + defer wg.Done() + if err := ip.pullInstallationImage(); err != nil { + e = append(e, err) + } + }() + + go func() { + defer wg.Done() + + opts := types.ContainerRemoveOptions{ + RemoveVolumes: true, + Force: true, + } + + if err := ip.client.ContainerRemove(context.Background(), ip.Server.Uuid+"_installer", opts); err != nil { + if !client.IsErrNotFound(err) { + e = append(e, err) + } + } + }() + + wg.Wait() + + // Maybe a better way to handle this, but if there is at least one error + // just bail out of the process now. + if len(e) > 0 { + return "", errors.WithStack(e[0]) + } + + return fileName, nil +} + +// Executes the installation process inside a specially created docker container. +func (ip *InstallationProcess) Execute() error { + installScriptPath, err := ip.beforeExecute() + if err != nil { + return errors.WithStack(err) + } + + ctx := context.Background() + + zap.S().Debugw( + "creating server installer container", + zap.String("server", ip.Server.Uuid), + zap.String("script_path", installScriptPath+"/install.sh"), + ) + + conf := &container.Config{ + Hostname: "installer", + AttachStdout: true, + AttachStderr: true, + AttachStdin: true, + OpenStdin: true, + Tty: true, + Cmd: []string{ip.Script.Entrypoint, "./mnt/install/install.sh"}, + Image: ip.Script.ContainerImage, + Env: ip.Server.GetEnvironmentVariables(), + Labels: map[string]string{ + "Service": "Pterodactyl", + "ContainerType": "server_installer", + }, + } + + hostConf := &container.HostConfig{ + Mounts: []mount.Mount{ + { + Target: "/mnt/server", + Source: ip.Server.Filesystem.Path(), + Type: mount.TypeBind, + ReadOnly: false, + }, + { + Target: "/mnt/install", + Source: installScriptPath, + Type: mount.TypeBind, + ReadOnly: false, + }, + }, + Tmpfs: map[string]string{ + "/tmp": "rw,exec,nosuid,size=50M", + }, + DNS: []string{"1.1.1.1", "8.8.8.8"}, + LogConfig: container.LogConfig{ + Type: "local", + Config: map[string]string{ + "max-size": "20m", + "max-file": "1", + "compress": "false", + }, + }, + Privileged: true, + NetworkMode: "pterodactyl_nw", + } + + zap.S().Infow("creating installer container for server process", zap.String("server", ip.Server.Uuid)) + r, err := ip.client.ContainerCreate(ctx, conf, hostConf, nil, ip.Server.Uuid+"_installer") + if err != nil { + return errors.WithStack(err) + } + + zap.S().Infow("running installation process for server", zap.String("server", ip.Server.Uuid)) + if err := ip.client.ContainerStart(ctx, r.ID, types.ContainerStartOptions{}); err != nil { + return err + } + + stream, err := ip.client.ContainerAttach(ctx, r.ID, types.ContainerAttachOptions{ + Stdout: true, + Stderr: true, + Stream: true, + }) + + if err != nil { + return errors.WithStack(err) + } + + wg := sync.WaitGroup{} + wg.Add(1) + + go func() { + defer stream.Close() + defer wg.Done() + + io.Copy(os.Stdout, stream.Reader) + }() + + wg.Wait() + + zap.S().Infow("completed installation process", zap.String("server", ip.Server.Uuid)) + + return nil +} diff --git a/server/server.go b/server/server.go index 84241e8..ff9df74 100644 --- a/server/server.go +++ b/server/server.go @@ -247,6 +247,33 @@ func FromConfiguration(data []byte, cfg *config.SystemConfiguration) (*Server, e return s, nil } +// Returns all of the environment variables that should be assigned to a running +// server instance. +func (s *Server) GetEnvironmentVariables() []string { + zone, _ := time.Now().In(time.Local).Zone() + + var out = []string{ + fmt.Sprintf("TZ=%s", zone), + fmt.Sprintf("STARTUP=%s", s.Invocation), + fmt.Sprintf("SERVER_MEMORY=%d", s.Build.MemoryLimit), + fmt.Sprintf("SERVER_IP=%s", s.Allocations.DefaultMapping.Ip), + fmt.Sprintf("SERVER_PORT=%d", s.Allocations.DefaultMapping.Port), + } + +eloop: + for k, v := range s.EnvVars { + for _, e := range out { + if strings.HasPrefix(e, strings.ToUpper(k)) { + continue eloop + } + } + + out = append(out, fmt.Sprintf("%s=%s", strings.ToUpper(k), v)) + } + + return out +} + // Syncs the state of the server on the Panel with Wings. This ensures that we're always // using the state of the server from the Panel and allows us to not require successful // API calls to Wings to do things.