a73x

internal/smoke/exposure.go

Ref:   Size: 4.6 KiB   History

package smoke

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"net"
	"os"
	"strconv"
	"strings"
	"time"
)

// sshBannerPrefix is what an sshd answers a fresh connection with, before any
// key exchange. Reading it back through a published port proves the whole path
// — the host listener, the splice, and the guest dial — with no infrastructure
// the fleet does not already have, and no privileged bind on either machine.
const sshBannerPrefix = "SSH-2.0"

// bannerFunc reads the first bytes a TCP peer sends after accepting.
type bannerFunc func(ctx context.Context, addr string) (string, error)

// echoFunc sends one datagram to a published UDP port and returns what came
// back on the same socket.
type echoFunc func(ctx context.Context, addr, payload string) (string, error)

// proveExposure publishes the smoke VM's ssh port on its host, dials the
// address the grant names, and expects an SSH banner. The exposure is revoked
// before the leg returns, whatever the outcome.
func proveExposure(ctx context.Context, c vmAPI, vmID string, now func() time.Time, sleep func(time.Duration), dial bannerFunc) error {
	exp, err := c.CreateExposure(ctx, vmID, 22, 0, "tcp")
	if err != nil {
		return fmt.Errorf("create exposure: %w", err)
	}
	defer func() {
		// Revoke on the way out even when the leg failed: the smoke leaves no
		// grant behind on the live fleet. A revoke that itself fails leaves a
		// published port on a live host, so say so — the leg's own verdict
		// stands either way.
		if err := c.DeleteExposure(context.WithoutCancel(ctx), exp.ID); err != nil {
			fmt.Fprintf(os.Stderr, "eitri-smoke: exposure revoke failed; grant %s left behind: %v\n", exp.ID, err)
		}
	}()

	if exp.HostAddr == "" {
		return errors.New("FAIL: the exposure names no host address; nothing to dial a published port on")
	}
	target := net.JoinHostPort(exp.HostAddr, strconv.FormatInt(exp.HostPort, 10))
	var lastErr error
	err = pollLoop(ctx, now, sleep, 60*time.Second, 3*time.Second, func() (bool, error) {
		banner, derr := dial(ctx, target)
		if derr != nil {
			// The listener binds on the host's next converge, a tick away — a
			// refused dial in that window is expected, not fatal.
			lastErr = derr
			return false, nil
		}
		if !strings.HasPrefix(banner, sshBannerPrefix) {
			return false, fmt.Errorf("FAIL: published port %s answered %q, want an %s banner",
				target, truncateBanner(banner), sshBannerPrefix)
		}
		return true, nil
	})
	if errors.Is(err, errPollTimeout) {
		return fmt.Errorf("FAIL: no %s banner from published port %s within 60s: %v", sshBannerPrefix, target, lastErr)
	}
	return err
}

// truncateBanner bounds what a wrong answer puts in the failure message.
func truncateBanner(s string) string {
	s = strings.TrimSpace(s)
	if len(s) > 64 {
		return s[:64]
	}
	return s
}

// readEcho is the real echoFunc: send payload to a published UDP port and read
// the answer off a connected socket, so a reply from anywhere but the port the
// grant names is not the fleet answering and does not count. The deadline
// bounds a datagram that goes nowhere — every hop here may drop one silently,
// which is what the caller's retry loop is for.
func readEcho(ctx context.Context, addr, payload string) (string, error) {
	var d net.Dialer
	conn, err := d.DialContext(ctx, "udp", addr)
	if err != nil {
		return "", err
	}
	defer conn.Close()
	if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
		return "", err
	}
	if _, err := conn.Write([]byte(payload)); err != nil {
		return "", err
	}
	buf := make([]byte, 512)
	n, err := conn.Read(buf)
	if err != nil {
		return "", err
	}
	return string(buf[:n]), nil
}

// readBanner is the real bannerFunc: dial addr and read the peer's first line,
// bounded by a deadline so a listener that binds but never answers fails the
// leg instead of hanging it. It reads until a newline (or 256 bytes) rather
// than taking one Read's worth, because a spliced connection can deliver the
// banner in fragments and a segment shorter than the prefix would fail a
// perfectly good sshd. A read that returns nothing at all keeps its error, so
// the caller can retry a listener that is not up yet.
func readBanner(ctx context.Context, addr string) (string, error) {
	var d net.Dialer
	conn, err := d.DialContext(ctx, "tcp", addr)
	if err != nil {
		return "", err
	}
	defer conn.Close()
	if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
		return "", err
	}
	buf := make([]byte, 256)
	n := 0
	for n < len(buf) {
		m, err := conn.Read(buf[n:])
		n += m
		if err != nil {
			if n == 0 {
				return "", err
			}
			break
		}
		if bytes.IndexByte(buf[:n], '\n') >= 0 {
			break
		}
	}
	return string(buf[:n]), nil
}