Ctrl K
ComunidadGuías…

iclient/incus.go

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package iclient

import (
	"bytes"
	"context"
	"crypto/sha256"
	"crypto/tls"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"maps"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/lxc/incus/v7/shared/api"
	incusTLS "github.com/lxc/incus/v7/shared/tls"
	"github.com/lxc/incus/v7/shared/util"
)

// Transport tuning; Go's default of 2 idle connections per host throttles a worker pool.
const (
	incusMaxIdleConns        = 128
	incusMaxIdleConnsPerHost = 32
	incusIdleConnTimeout     = 90 * time.Second
	incusDialTimeout         = 10 * time.Second
	incusTLSHandshakeTimeout = 5 * time.Second

	// An operation wait long-polls, hence the hour.
	incusResponseHeaderTimeout = time.Hour
	incusExpectContinueTimeout = 30 * time.Second
)

// Connection talks the Incus REST API to one daemon.
type Connection struct {
	http      *http.Client
	baseURL   string
	userAgent string

	// Reported by GetConnectionInfo; socketPath is empty over TLS.
	socketPath string
	serverCert string

	// events is this Connection's own listeners, never shared with a copy.
	events *incusEvents

	// eventSilence is how long an event socket may say nothing before it counts
	// as dead. A test shortens it, having no half hour to wait.
	eventSilence time.Duration

	// eventUpgrade is how long the server has to accept an event socket.
	eventUpgrade time.Duration
}

// NewConnection dials an Incus daemon, over its unix socket or over TLS.
func NewConnection(info *ConfigRemoteInfo) (*Connection, error) {
	if len(info.Addrs) == 0 {
		return nil, fmt.Errorf("%q: %w", info.Name, ErrConnectionNoAddress)
	}

	c := &Connection{
		userAgent:    info.UserAgent,
		serverCert:   info.ServerCert,
		events:       &incusEvents{},
		eventSilence: incusEventSilence,
		eventUpgrade: incusTLSHandshakeTimeout,
	}

	// Only the first address is tried, where upstream probes the whole rolling list.
	addr := info.Addrs[0]

	transport := incusTransport()

	if strings.HasPrefix(addr, "unix:") {
		socket := incusSocketPath(addr)

		transport.DialContext = func(ctx context.Context, _ string, _ string) (net.Conn, error) {
			return (&net.Dialer{Timeout: incusDialTimeout}).DialContext(ctx, "unix", socket)
		}

		c.socketPath = socket
		c.baseURL = "http://unix.socket"
		c.http = &http.Client{Transport: transport}

		return c, nil
	}

	var expectedFingerprint string
	if info.ServerFingerprint != "" {
		expectedFingerprint = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(info.ServerFingerprint), ":", ""))
		var hexErr error
		_, hexErr = hex.DecodeString(expectedFingerprint)
		if hexErr != nil || len(expectedFingerprint) != 64 {
			return nil, fmt.Errorf("%q: invalid server certificate fingerprint: must be 64-character SHA-256 hex string", info.Name)
		}
	}

	tlsConfig, err := incusTLS.GetTLSConfigMem(info.ClientCert, info.ClientKey, info.ClientCA, info.ServerCert, info.InsecureSkipVerify)
	if err != nil {
		return nil, fmt.Errorf("%q: building the TLS config: %w", info.Name, err)
	}

	if expectedFingerprint != "" {
		tlsConfig.InsecureSkipVerify = true
		tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error {
			if len(cs.PeerCertificates) == 0 {
				return errors.New("no certificate presented by server")
			}
			actual := fmt.Sprintf("%x", sha256.Sum256(cs.PeerCertificates[0].Raw))
			if actual != expectedFingerprint {
				return fmt.Errorf("server certificate fingerprint mismatch: expected %s, got %s", expectedFingerprint, actual)
			}
			return nil
		}
	}

	transport.TLSClientConfig = tlsConfig
	transport.Proxy = http.ProxyFromEnvironment

	c.baseURL = strings.TrimSuffix(addr, "/")
	c.http = &http.Client{Transport: transport}

	return c, nil
}

// incusTransport is the tuning both address kinds share.
//
// There is deliberately no http.Client.Timeout: it bounds the whole request,
// which would cut off the event stream, an operation long-poll and every
// console or SFTP transfer.
func incusTransport() *http.Transport {
	resolverDial := &net.Dialer{Timeout: incusDialTimeout}
	resolver := &net.Resolver{PreferGo: true, Dial: resolverDial.DialContext}
	dialer := &net.Dialer{Timeout: incusDialTimeout, Resolver: resolver}

	return &http.Transport{
		DialContext: dialer.DialContext,

		// Upstream sets DisableKeepAlives, paying a TCP and TLS handshake per request.
		DisableKeepAlives:   false,
		MaxIdleConns:        incusMaxIdleConns,
		MaxIdleConnsPerHost: incusMaxIdleConnsPerHost,

		// Unbounded on purpose: the worker pool is where concurrency is bounded.
		MaxConnsPerHost: 0,

		IdleConnTimeout:     incusIdleConnTimeout,
		TLSHandshakeTimeout: incusTLSHandshakeTimeout,

		// Time to the first byte: an operation wait sends no header until it finishes.
		ResponseHeaderTimeout: incusResponseHeaderTimeout,
		ExpectContinueTimeout: incusExpectContinueTimeout,

		// Events and exec need an HTTP/1.1 upgrade, which h2 does not do.
		ForceAttemptHTTP2: false,
	}
}

// WithMaxIdleConns returns a copy that keeps at most conns idle connections,
// perHost of them to any one host. The copy starts with a pool of its own,
// because resizing a live one under in-flight requests is a race.
//
// Nothing has to be handed back. A Connection is not a resource with a lifetime
// of its own: what it holds is a transport, and an abandoned one closes its idle
// sockets after incusIdleConnTimeout and is then collected. Callers drop
// connections; they do not close them.
func (c *Connection) WithMaxIdleConns(conns int, perHost int) *Connection {
	clone := *c

	// Listeners are never inherited: a copy that listens gets a socket of its own.
	clone.events = &incusEvents{}

	transport, ok := c.http.Transport.(*http.Transport)
	if !ok {
		// Nothing to retune, so the copy shares c's pool.
		return &clone
	}

	// Clone carries the dialer, the TLS config and the rest of the tuning over.
	tuned := transport.Clone()
	tuned.MaxIdleConns = conns
	tuned.MaxIdleConnsPerHost = perHost

	client := *c.http
	client.Transport = tuned
	clone.http = &client

	return &clone
}

// incusSocketPath resolves the socket for a unix address: the path it carries,
// else INCUS_SOCKET, else INCUS_DIR, else the usual places.
func incusSocketPath(addr string) string {
	path := strings.TrimPrefix(strings.TrimPrefix(addr, "unix://"), "unix:")
	if path != "" {
		return path
	}

	path = os.Getenv("INCUS_SOCKET")
	if path != "" {
		return path
	}

	dir := os.Getenv("INCUS_DIR")
	if dir == "" {
		_, err := os.Lstat("/run/incus/unix.socket")
		if err == nil {
			dir = "/run/incus"
		} else {
			dir = "/var/lib/incus"
		}
	}

	socket := filepath.Join(dir, "unix.socket")

	// incus-user hands unprivileged callers a socket of their own.
	userSocket := filepath.Join(dir, "unix.socket.user")
	if !util.PathIsWritable(socket) && util.PathIsWritable(userSocket) {
		return userSocket
	}

	return socket
}

// uriFor renders a path under /1.0 in project. An empty project sends none,
// which incusd reads as the default project.
func uriFor(project string, path string, query url.Values) string {
	if project != "" {
		if query == nil {
			query = url.Values{}
		}

		query.Set("project", project)
	}

	uri := "/1.0" + path
	if len(query) > 0 {
		uri += "?" + query.Encode()
	}

	return uri
}

// do runs one request against /1.0 in project, and returns the decoded envelope
// plus the ETag a conditional update needs.
func (c *Connection) do(ctx context.Context, project string, method string, path string, query url.Values, body any, etag string) (*api.Response, string, error) {
	return c.doPath(ctx, method, uriFor(project, path, query), body, etag)
}

// doPath is do without the /1.0 prefix or the project, for a caller that has
// built the whole path itself.
func (c *Connection) doPath(ctx context.Context, method string, path string, body any, etag string) (*api.Response, string, error) {
	var reader io.Reader

	contentType := ""

	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			return nil, "", fmt.Errorf("encoding the request body: %w", err)
		}

		reader = bytes.NewReader(encoded)
		contentType = "application/json"
	}

	return c.send(ctx, method, path, reader, contentType, etag, nil)
}

// send issues one request and decodes the API envelope it answers with. header
// carries what an upload cannot put in the body.
func (c *Connection) send(ctx context.Context, method string, path string, body io.Reader, contentType string, etag string, header http.Header) (*api.Response, string, error) {
	req, err := c.request(ctx, method, path, body)
	if err != nil {
		return nil, "", err
	}

	req.Header.Set("Accept", "application/json")

	if contentType != "" {
		req.Header.Set("Content-Type", contentType)
	}

	if etag != "" {
		req.Header.Set("If-Match", etag)
	}

	maps.Copy(req.Header, header)

	resp, err := c.http.Do(req) //nolint:gosec
	if err != nil {
		return nil, "", err
	}

	defer func() { _ = resp.Body.Close() }()

	decoded := &api.Response{}

	err = json.NewDecoder(resp.Body).Decode(decoded)
	if err != nil {
		return nil, "", fmt.Errorf("decoding %s %s: %w", method, path, err)
	}

	if decoded.Type == api.ErrorResponse {
		return nil, "", busyError(api.StatusErrorf(decoded.Code, "%s", decoded.Error), decoded.Error)
	}

	return decoded, resp.Header.Get("ETag"), nil
}

// doRaw hands back the undecoded body of a /1.0 endpoint that answers with
// something other than an API envelope. The caller closes it.
func (c *Connection) doRaw(ctx context.Context, project string, method string, path string, query url.Values) (io.ReadCloser, error) {
	req, err := c.request(ctx, method, uriFor(project, path, query), nil)
	if err != nil {
		return nil, err
	}

	resp, err := c.http.Do(req) //nolint:gosec
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		defer func() { _ = resp.Body.Close() }()

		// A failure still answers with an envelope.
		decoded := &api.Response{}

		err = json.NewDecoder(resp.Body).Decode(decoded)
		if err != nil {
			return nil, api.StatusErrorf(resp.StatusCode, "%s %s: %s", method, path, resp.Status)
		}

		return nil, api.StatusErrorf(decoded.Code, "%s", decoded.Error)
	}

	return resp.Body, nil
}

// request builds a request against the connection's server.
func (c *Connection) request(ctx context.Context, method string, path string, body io.Reader) (*http.Request, error) {
	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) //nolint:gosec // the address is the configured remote's
	if err != nil {
		return nil, err
	}

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

	return req, nil
}

// resourceNames turns the resource URLs a non-recursive collection returns
// into bare names.
func resourceNames(collection string, uris []string) ([]string, error) {
	names := make([]string, 0, len(uris))

	for _, uri := range uris {
		parsed, err := url.Parse(uri)
		if err != nil {
			return nil, fmt.Errorf("parsing the resource URL %q: %w", uri, err)
		}

		_, name, found := strings.Cut(parsed.Path, collection+"/")
		if !found {
			return nil, fmt.Errorf("unexpected resource URL %q", uri)
		}

		names = append(names, name)
	}

	return names, nil
}

// getStruct GETs path in project and unmarshals the response metadata into target.
func (c *Connection) getStruct(ctx context.Context, project string, path string, query url.Values, target any) (string, error) {
	resp, etag, err := c.do(ctx, project, http.MethodGet, path, query, nil, "")
	if err != nil {
		return "", err
	}

	err = resp.MetadataAsStruct(target)
	if err != nil {
		return "", fmt.Errorf("decoding the metadata of GET %s: %w", path, err)
	}

	return etag, nil
}