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
}