426 lines
9.8 KiB
Go
426 lines
9.8 KiB
Go
package plugins
|
|
|
|
import (
|
|
zipa "archive/zip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-retryablehttp"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/traefik/traefik/v3/pkg/logs"
|
|
"golang.org/x/mod/module"
|
|
"golang.org/x/mod/zip"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
sourcesFolder = "sources"
|
|
archivesFolder = "archives"
|
|
stateFilename = "state.json"
|
|
goPathSrc = "src"
|
|
pluginManifest = ".traefik.yml"
|
|
)
|
|
|
|
const pluginsURL = "https://plugins.traefik.io/public/"
|
|
|
|
const (
|
|
hashHeader = "X-Plugin-Hash"
|
|
)
|
|
|
|
// ClientOptions the options of a Traefik plugins client.
|
|
type ClientOptions struct {
|
|
Output string
|
|
}
|
|
|
|
// Client a Traefik plugins client.
|
|
type Client struct {
|
|
HTTPClient *http.Client
|
|
baseURL *url.URL
|
|
|
|
archives string
|
|
stateFile string
|
|
goPath string
|
|
sources string
|
|
}
|
|
|
|
// NewClient creates a new Traefik plugins client.
|
|
func NewClient(opts ClientOptions) (*Client, error) {
|
|
baseURL, err := url.Parse(pluginsURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sourcesRootPath := filepath.Join(filepath.FromSlash(opts.Output), sourcesFolder)
|
|
err = resetDirectory(sourcesRootPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
goPath, err := os.MkdirTemp(sourcesRootPath, "gop-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create GoPath: %w", err)
|
|
}
|
|
|
|
archivesPath := filepath.Join(filepath.FromSlash(opts.Output), archivesFolder)
|
|
err = os.MkdirAll(archivesPath, 0o755)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create archives directory %s: %w", archivesPath, err)
|
|
}
|
|
|
|
client := retryablehttp.NewClient()
|
|
client.Logger = logs.NewRetryableHTTPLogger(log.Logger)
|
|
client.HTTPClient = &http.Client{Timeout: 10 * time.Second}
|
|
client.RetryMax = 3
|
|
|
|
return &Client{
|
|
HTTPClient: client.StandardClient(),
|
|
baseURL: baseURL,
|
|
|
|
archives: archivesPath,
|
|
stateFile: filepath.Join(archivesPath, stateFilename),
|
|
|
|
goPath: goPath,
|
|
sources: filepath.Join(goPath, goPathSrc),
|
|
}, nil
|
|
}
|
|
|
|
// GoPath gets the plugins GoPath.
|
|
func (c *Client) GoPath() string {
|
|
return c.goPath
|
|
}
|
|
|
|
// ReadManifest reads a plugin manifest.
|
|
func (c *Client) ReadManifest(moduleName string) (*Manifest, error) {
|
|
return ReadManifest(c.goPath, moduleName)
|
|
}
|
|
|
|
// ReadManifest reads a plugin manifest.
|
|
func ReadManifest(goPath, moduleName string) (*Manifest, error) {
|
|
p := filepath.Join(goPath, goPathSrc, filepath.FromSlash(moduleName), pluginManifest)
|
|
|
|
file, err := os.Open(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open the plugin manifest %s: %w", p, err)
|
|
}
|
|
|
|
defer func() { _ = file.Close() }()
|
|
|
|
m := &Manifest{}
|
|
err = yaml.NewDecoder(file).Decode(m)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode the plugin manifest %s: %w", p, err)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// Download downloads a plugin archive.
|
|
func (c *Client) Download(ctx context.Context, pName, pVersion string) (string, error) {
|
|
filename := c.buildArchivePath(pName, pVersion)
|
|
|
|
var hash string
|
|
_, err := os.Stat(filename)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return "", fmt.Errorf("failed to read archive %s: %w", filename, err)
|
|
}
|
|
|
|
if err == nil {
|
|
hash, err = computeHash(filename)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to compute hash: %w", err)
|
|
}
|
|
}
|
|
|
|
endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "download", pName, pVersion))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse endpoint URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
if hash != "" {
|
|
req.Header.Set(hashHeader, hash)
|
|
}
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to call service: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusNotModified:
|
|
// noop
|
|
return hash, nil
|
|
|
|
case http.StatusOK:
|
|
err = os.MkdirAll(filepath.Dir(filename), 0o755)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
var file *os.File
|
|
file, err = os.Create(filename)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create file %q: %w", filename, err)
|
|
}
|
|
|
|
defer func() { _ = file.Close() }()
|
|
|
|
_, err = io.Copy(file, resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to write response: %w", err)
|
|
}
|
|
|
|
hash, err = computeHash(filename)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to compute hash: %w", err)
|
|
}
|
|
|
|
return hash, nil
|
|
|
|
default:
|
|
data, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("error: %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
}
|
|
|
|
// Check checks the plugin archive integrity.
|
|
func (c *Client) Check(ctx context.Context, pName, pVersion, hash string) error {
|
|
endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "validate", pName, pVersion))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse endpoint URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
if hash != "" {
|
|
req.Header.Set(hashHeader, hash)
|
|
}
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to call service: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("plugin integrity check failed")
|
|
}
|
|
|
|
// Unzip unzip a plugin archive.
|
|
func (c *Client) Unzip(pName, pVersion string) error {
|
|
err := c.unzipModule(pName, pVersion)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
return c.unzipArchive(pName, pVersion)
|
|
}
|
|
|
|
func (c *Client) unzipModule(pName, pVersion string) error {
|
|
src := c.buildArchivePath(pName, pVersion)
|
|
dest := filepath.Join(c.sources, filepath.FromSlash(pName))
|
|
|
|
return zip.Unzip(dest, module.Version{Path: pName, Version: pVersion}, src)
|
|
}
|
|
|
|
func (c *Client) unzipArchive(pName, pVersion string) error {
|
|
zipPath := c.buildArchivePath(pName, pVersion)
|
|
|
|
archive, err := zipa.OpenReader(zipPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() { _ = archive.Close() }()
|
|
|
|
dest := filepath.Join(c.sources, filepath.FromSlash(pName))
|
|
|
|
for _, f := range archive.File {
|
|
err = unzipFile(f, dest)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to unzip %s: %w", f.Name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func unzipFile(f *zipa.File, dest string) error {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() { _ = rc.Close() }()
|
|
|
|
pathParts := strings.SplitN(f.Name, "/", 2)
|
|
p := filepath.Join(dest, pathParts[1])
|
|
|
|
if f.FileInfo().IsDir() {
|
|
err = os.MkdirAll(p, f.Mode())
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create archive directory %s: %w", p, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
err = os.MkdirAll(filepath.Dir(p), 0o750)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create archive directory %s for file %s: %w", filepath.Dir(p), p, err)
|
|
}
|
|
|
|
elt, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() { _ = elt.Close() }()
|
|
|
|
_, err = io.Copy(elt, rc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CleanArchives cleans plugins archives.
|
|
func (c *Client) CleanArchives(plugins map[string]Descriptor) error {
|
|
if _, err := os.Stat(c.stateFile); os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
|
|
stateFile, err := os.Open(c.stateFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open state file %s: %w", c.stateFile, err)
|
|
}
|
|
|
|
previous := make(map[string]string)
|
|
err = json.NewDecoder(stateFile).Decode(&previous)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode state file %s: %w", c.stateFile, err)
|
|
}
|
|
|
|
for pName, pVersion := range previous {
|
|
for _, desc := range plugins {
|
|
if desc.ModuleName == pName && desc.Version != pVersion {
|
|
archivePath := c.buildArchivePath(pName, pVersion)
|
|
if err = os.RemoveAll(archivePath); err != nil {
|
|
return fmt.Errorf("failed to remove archive %s: %w", archivePath, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WriteState writes the plugins state files.
|
|
func (c *Client) WriteState(plugins map[string]Descriptor) error {
|
|
m := make(map[string]string)
|
|
|
|
for _, descriptor := range plugins {
|
|
m[descriptor.ModuleName] = descriptor.Version
|
|
}
|
|
|
|
mp, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("unable to marshal plugin state: %w", err)
|
|
}
|
|
|
|
return os.WriteFile(c.stateFile, mp, 0o600)
|
|
}
|
|
|
|
// ResetAll resets all plugins related directories.
|
|
func (c *Client) ResetAll() error {
|
|
if c.goPath == "" {
|
|
return errors.New("goPath is empty")
|
|
}
|
|
|
|
err := resetDirectory(filepath.Join(c.goPath, ".."))
|
|
if err != nil {
|
|
return fmt.Errorf("unable to reset plugins GoPath directory %s: %w", c.goPath, err)
|
|
}
|
|
|
|
err = resetDirectory(c.archives)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to reset plugins archives directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) buildArchivePath(pName, pVersion string) string {
|
|
return filepath.Join(c.archives, filepath.FromSlash(pName), pVersion+".zip")
|
|
}
|
|
|
|
func resetDirectory(dir string) error {
|
|
dirPath, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to get absolute path of %s: %w", dir, err)
|
|
}
|
|
|
|
currentPath, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to get the current directory: %w", err)
|
|
}
|
|
|
|
if strings.HasPrefix(currentPath, dirPath) {
|
|
return fmt.Errorf("cannot be deleted: the directory path %s is the parent of the current path %s", dirPath, currentPath)
|
|
}
|
|
|
|
err = os.RemoveAll(dir)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to remove directory %s: %w", dirPath, err)
|
|
}
|
|
|
|
err = os.MkdirAll(dir, 0o755)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create directory %s: %w", dirPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func computeHash(filepath string) (string, error) {
|
|
file, err := os.Open(filepath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
hash := sha256.New()
|
|
|
|
if _, err := io.Copy(hash, file); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sum := hash.Sum(nil)
|
|
|
|
return hex.EncodeToString(sum), nil
|
|
}
|