portainer/portainer

View on GitHub
api/http/handler/hostmanagement/openamt/amtrpc.go

Summary

Maintainability
B
4 hrs
Test Coverage
package openamt

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"

    portainer "github.com/portainer/portainer/api"
    "github.com/portainer/portainer/api/hostmanagement/openamt"
    httperror "github.com/portainer/portainer/pkg/libhttp/error"
    "github.com/portainer/portainer/pkg/libhttp/request"
    "github.com/portainer/portainer/pkg/libhttp/response"

    "github.com/docker/docker/api/types"
    "github.com/docker/docker/api/types/container"
    "github.com/docker/docker/api/types/filters"
    "github.com/docker/docker/api/types/network"
    "github.com/docker/docker/client"
    "github.com/rs/zerolog/log"
    "github.com/segmentio/encoding/json"
)

type HostInfo struct {
    EndpointID     portainer.EndpointID `json:"EndpointID"`
    RawOutput      string               `json:"RawOutput"`
    AMT            string               `json:"AMT"`
    UUID           string               `json:"UUID"`
    DNSSuffix      string               `json:"DNS Suffix"`
    BuildNumber    string               `json:"Build Number"`
    ControlMode    string               `json:"Control Mode"`
    ControlModeRaw int                  `json:"Control Mode (Raw)"`
}

const (
    // TODO: this should get extracted to some configurable - don't assume Docker Hub is everyone's global namespace, or that they're allowed to pull images from the internet
    rpcGoImageName      = "ptrrd/openamt:rpc-go-json"
    rpcGoContainerName  = "openamt-rpc-go"
    dockerClientTimeout = 5 * time.Minute
)

// @id OpenAMTHostInfo
// @summary Request OpenAMT info from a node
// @description Request OpenAMT info from a node
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/info [get]
func (handler *Handler) openAMTHostInfo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
    endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
    if err != nil {
        return httperror.BadRequest("Invalid environment identifier route variable", err)
    }

    log.Info().Int("endpointID", endpointID).Msg("OpenAMTHostInfo")

    endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
    if handler.DataStore.IsErrObjectNotFound(err) {
        return httperror.NotFound("Unable to find an endpoint with the specified identifier inside the database", err)
    } else if err != nil {
        return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err)
    }

    amtInfo, output, err := handler.getEndpointAMTInfo(endpoint)
    if err != nil {
        return httperror.InternalServerError(output, err)
    }

    return response.JSON(w, amtInfo)
}

func (handler *Handler) getEndpointAMTInfo(endpoint *portainer.Endpoint) (*HostInfo, string, error) {
    ctx := context.TODO()

    // pull the image so we can check if there's a new one
    // TODO: these should be able to be over-ridden (don't hardcode the assumption that secure users can access Docker Hub, or that its even the orchestrator's "global namespace")
    cmdLine := []string{"amtinfo", "--json"}
    output, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
    if err != nil {
        return nil, output, err
    }

    amtInfo := HostInfo{}
    _ = json.Unmarshal([]byte(output), &amtInfo)

    amtInfo.EndpointID = endpoint.ID
    amtInfo.RawOutput = output

    return &amtInfo, "", nil
}

func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *portainer.Endpoint, imageName, containerName string, cmdLine []string) (output string, err error) {
    // TODO: this should not be Docker specific
    // TODO: extract from this Handler into something global.

    // TODO: start
    //      docker run --rm -it --privileged ptrrd/openamt:rpc-go amtinfo
    //        on the Docker standalone node (one per env :)
    //        and later, on the specified node in the swarm, or kube.
    nodeName := ""
    timeout := dockerClientTimeout
    docker, err := handler.DockerClientFactory.CreateClient(endpoint, nodeName, &timeout)
    if err != nil {
        return "Unable to create Docker Client connection", err
    }
    defer docker.Close()

    if err := pullImage(ctx, docker, imageName); err != nil {
        return "Could not pull image from registry", err
    }

    output, err = runContainer(ctx, docker, imageName, containerName, cmdLine)
    if err != nil {
        return "Could not run container", err
    }

    return output, nil
}

// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// TODO: the idea being that if we have an internal struct of a parsed compose file, we can also populate that struct programmatically, and run it to get the result I'm getting here.
// TODO: likely an upgrade and abstraction of DeployComposeStack/DeploySwarmStack/DeployKubernetesStack
// pullImage will pull the image to the specified environment
// TODO: add k8s implementation
// TODO: work out registry auth
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
    out, err := docker.ImagePull(ctx, imageName, types.ImagePullOptions{})
    if err != nil {
        log.Error().Str("image_name", imageName).Err(err).Msg("could not pull image from registry")

        return err
    }

    defer out.Close()
    outputBytes, err := io.ReadAll(out)
    if err != nil {
        log.Error().Str("image_name", imageName).Err(err).Msg("could not read image pull output")

        return err
    }

    log.Debug().Str("image_name", imageName).Str("output", string(outputBytes)).Msg("image pulled")

    return nil
}

// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// runContainer should be used to run a short command that returns information to stdout
// TODO: add k8s support
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
    opts := container.ListOptions{All: true}
    opts.Filters = filters.NewArgs()
    opts.Filters.Add("name", containerName)
    existingContainers, err := docker.ContainerList(ctx, opts)
    if err != nil {
        log.Error().
            Str("image_name", imageName).
            Str("container_name", containerName).
            Err(err).
            Msg("listing existing container")

        return "", err
    }

    if len(existingContainers) > 0 {
        err = docker.ContainerRemove(ctx, existingContainers[0].ID, container.RemoveOptions{Force: true})
        if err != nil {
            log.Error().
                Str("image_name", imageName).
                Str("container_name", containerName).
                Err(err).
                Msg("removing existing container")

            return "", err
        }
    }

    created, err := docker.ContainerCreate(
        ctx,
        &container.Config{
            Image:        imageName,
            Cmd:          cmdLine,
            Env:          []string{},
            Tty:          true,
            OpenStdin:    true,
            AttachStdout: true,
            AttachStderr: true,
        },
        &container.HostConfig{
            Privileged: true,
        },
        &network.NetworkingConfig{},
        nil,
        containerName,
    )

    if err != nil {
        log.Error().
            Str("image_name", imageName).
            Str("container_name", containerName).
            Err(err).
            Msg("creating container")

        return "", err
    }

    err = docker.ContainerStart(ctx, created.ID, container.StartOptions{})
    if err != nil {
        log.Error().
            Str("image_name", imageName).
            Str("container_name", containerName).
            Err(err).
            Msg("starting container")

        return "", err
    }

    log.Debug().Str("container_name", containerName).Msg("container created and started")

    statusCh, errCh := docker.ContainerWait(ctx, created.ID, container.WaitConditionNotRunning)
    var statusCode int64
    select {
    case err := <-errCh:
        if err != nil {
            log.Error().
                Str("image_name", imageName).
                Str("container_name", containerName).
                Err(err).
                Msg("starting container")

            return "", err
        }
    case status := <-statusCh:
        statusCode = status.StatusCode
    }

    log.Debug().Int64("status", statusCode).Msg("container wait status")

    out, err := docker.ContainerLogs(ctx, created.ID, container.LogsOptions{ShowStdout: true})
    if err != nil {
        log.Error().Err(err).Str("image_name", imageName).Str("container_name", containerName).Msg("getting container log")

        return "", err
    }

    err = docker.ContainerRemove(ctx, created.ID, container.RemoveOptions{})
    if err != nil {
        log.Error().
            Str("image_name", imageName).
            Str("container_name", containerName).
            Err(err).
            Msg("removing container")

        return "", err
    }

    outputBytes, err := io.ReadAll(out)
    if err != nil {
        log.Error().
            Str("image_name", imageName).
            Str("container_name", containerName).
            Err(err).
            Msg("read container output")

        return "", err
    }

    log.Debug().
        Str("container_name", containerName).
        Str("output", string(outputBytes)).
        Msg("container finished with output")

    return string(outputBytes), nil
}

func (handler *Handler) activateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
    ctx := context.TODO()

    config := settings.OpenAMTConfiguration
    cmdLine := []string{
        "activate",
        "-n",
        "-v",
        "-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
        "-profile", openamt.DefaultProfileName,
        "-d", config.DomainName,
        "-password", config.MPSPassword,
    }

    _, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)

    return err
}