mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
234 lines
6.4 KiB
Go
234 lines
6.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"tailscale.com/net/tsaddr"
|
|
)
|
|
|
|
type pfProbeResult uint8
|
|
|
|
const (
|
|
pfProbeIndeterminate pfProbeResult = iota
|
|
pfProbeIntercepted
|
|
pfProbeNotIntercepted
|
|
)
|
|
|
|
func (r pfProbeResult) String() string {
|
|
switch r {
|
|
case pfProbeIntercepted:
|
|
return "intercepted"
|
|
case pfProbeNotIntercepted:
|
|
return "not_intercepted"
|
|
default:
|
|
return "indeterminate"
|
|
}
|
|
}
|
|
|
|
type pfProbeObservation struct {
|
|
result pfProbeResult
|
|
stage string
|
|
code string
|
|
probeID string
|
|
recoveryGeneration uint64
|
|
target string
|
|
}
|
|
|
|
// Conditions exclude attempt IDs and generations so repeated failures stay bounded.
|
|
type pfProbeCondition struct {
|
|
result pfProbeResult
|
|
stage, code, target string
|
|
}
|
|
|
|
type pfProbeLogState struct {
|
|
mu sync.Mutex
|
|
previous pfProbeCondition
|
|
initialized bool
|
|
repeats uint64
|
|
}
|
|
|
|
// change returns whether to retain a transition and the preceding repeat count.
|
|
// A restored receipt is retained too, so an old warning does not imply an outage.
|
|
func (s *pfProbeLogState) change(o pfProbeObservation) (retain bool, repeats uint64) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
condition := pfProbeCondition{o.result, o.stage, o.code, o.target}
|
|
if s.initialized && condition == s.previous {
|
|
s.repeats++
|
|
return false, s.repeats
|
|
}
|
|
retain = o.result != pfProbeIntercepted || (s.initialized && s.previous.result != pfProbeIntercepted)
|
|
repeats = s.repeats
|
|
s.previous, s.initialized, s.repeats = condition, true, 0
|
|
return retain, repeats
|
|
}
|
|
|
|
// OsResolverNameservers groups LAN entries before public entries. Preserve a usable
|
|
// first target, including public DNS. Otherwise scan only the LAN group. Stop at
|
|
// the public boundary, not at a particular IP: the public group can be synthetic.
|
|
func pfProbeTarget(servers []string) string {
|
|
for i, server := range servers {
|
|
host, _, err := net.SplitHostPort(server)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
ip, err := netip.ParseAddr(host)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
lan := ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
|
|
usable := ip.Is4() && !ip.IsLoopback() && !ip.IsUnspecified() && !ip.IsMulticast()
|
|
if usable && (i == 0 || lan) {
|
|
return host
|
|
}
|
|
if !lan {
|
|
return ""
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Keep the helper protocol bounded and free of resolver IDs, queries and raw OS errors.
|
|
func pfProbeErrorCode(err error) string {
|
|
switch {
|
|
case errors.Is(err, syscall.ENETUNREACH), errors.Is(err, syscall.EHOSTUNREACH):
|
|
return "unreachable"
|
|
case errors.Is(err, syscall.EADDRNOTAVAIL):
|
|
return "source_unavailable"
|
|
case errors.Is(err, syscall.EACCES), errors.Is(err, syscall.EPERM):
|
|
return "permission"
|
|
default:
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
return "timeout"
|
|
}
|
|
return "io"
|
|
}
|
|
}
|
|
|
|
func sendPFProbe(host, hexPacket string, dial func(string, string, time.Duration) (net.Conn, error), status io.Writer) error {
|
|
fail := func(stage string, err error) error {
|
|
_, _ = fmt.Fprintf(status, "%s:%s\n", stage, pfProbeErrorCode(err))
|
|
return err
|
|
}
|
|
packet, err := hex.DecodeString(hexPacket)
|
|
if err != nil {
|
|
_, _ = io.WriteString(status, "decode:invalid_argument\n")
|
|
return err
|
|
}
|
|
conn, err := dial("udp4", net.JoinHostPort(host, "53"), time.Second)
|
|
if err != nil {
|
|
return fail("dial", err)
|
|
}
|
|
defer conn.Close()
|
|
if err := conn.SetDeadline(time.Now().Add(time.Second)); err != nil {
|
|
return fail("deadline", err)
|
|
}
|
|
n, err := conn.Write(packet)
|
|
if err == nil && n != len(packet) {
|
|
err = io.ErrShortWrite
|
|
}
|
|
if err != nil {
|
|
return fail("write", err)
|
|
}
|
|
if _, err := io.WriteString(status, "sent\n"); err != nil {
|
|
return err
|
|
}
|
|
// Keep the socket alive for the local handler's response. A read failure does
|
|
// not undo the successful send acknowledged above.
|
|
_, _ = conn.Read(make([]byte, 512))
|
|
return nil
|
|
}
|
|
|
|
func parsePFProbeStatus(status string) pfProbeObservation {
|
|
if status == "sent\n" {
|
|
return pfProbeObservation{stage: "sent"}
|
|
}
|
|
stage, code, ok := strings.Cut(strings.TrimSuffix(status, "\n"), ":")
|
|
if ok && (stage == "decode" || stage == "dial" || stage == "deadline" || stage == "write") {
|
|
switch code {
|
|
case "unreachable", "source_unavailable", "permission", "timeout", "io", "invalid_argument":
|
|
return pfProbeObservation{stage: stage, code: code}
|
|
}
|
|
}
|
|
return pfProbeObservation{stage: "helper", code: "invalid_status"}
|
|
}
|
|
|
|
func pfProbeContextCode(ctx context.Context) string {
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
return "timeout"
|
|
}
|
|
return "canceled"
|
|
}
|
|
|
|
// Receipt by the local handler is conclusive. A missing receipt is a failed
|
|
// interception test only after the child confirms a successful packet send.
|
|
func runPFProbe(ctx context.Context, cmd *exec.Cmd, received <-chan struct{}, deliveryTimeout time.Duration) pfProbeObservation {
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return pfProbeObservation{stage: "start", code: pfProbeErrorCode(err)}
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
_ = stdout.Close()
|
|
return pfProbeObservation{stage: "start", code: pfProbeErrorCode(err)}
|
|
}
|
|
statusCh := make(chan pfProbeObservation, 1)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
line, _ := bufio.NewReader(io.LimitReader(stdout, 128)).ReadString('\n')
|
|
statusCh <- parsePFProbeStatus(line)
|
|
_ = cmd.Wait()
|
|
close(done)
|
|
}()
|
|
defer func() {
|
|
_ = cmd.Process.Kill()
|
|
<-done
|
|
}()
|
|
|
|
var sent pfProbeObservation
|
|
select {
|
|
case <-received:
|
|
return pfProbeObservation{result: pfProbeIntercepted, stage: "received"}
|
|
case <-ctx.Done():
|
|
return pfProbeObservation{stage: "helper", code: pfProbeContextCode(ctx)}
|
|
case sent = <-statusCh:
|
|
}
|
|
if sent.stage != "sent" {
|
|
select {
|
|
case <-received:
|
|
return pfProbeObservation{result: pfProbeIntercepted, stage: "received"}
|
|
default:
|
|
return sent
|
|
}
|
|
}
|
|
|
|
timer := time.NewTimer(deliveryTimeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-received:
|
|
return pfProbeObservation{result: pfProbeIntercepted, stage: "received"}
|
|
case <-ctx.Done():
|
|
return pfProbeObservation{stage: "delivery", code: pfProbeContextCode(ctx)}
|
|
case <-timer.C:
|
|
// Prefer receipt when notification and deadline became ready together.
|
|
select {
|
|
case <-received:
|
|
return pfProbeObservation{result: pfProbeIntercepted, stage: "received"}
|
|
default:
|
|
return pfProbeObservation{result: pfProbeNotIntercepted, stage: "delivery", code: "timeout"}
|
|
}
|
|
}
|
|
}
|