Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,47 @@ is the constant in `cmd/lattice-agent/main.go`, prepared for the next tag but
not yet tagged; the release workflow injects the tag at build time and must
match it.

## 0.3.10-alpha.10 (unreleased)

Design 32 S5a: the agent runs dns checks, which ask one resolver one question
from each source and judge the answer. This is the acceptance probe for an
encrypted resolver: one check definition from several vantage points, every
five minutes, judged on the answer and timed per source. Pins the lattice-sdk
commit that models the method (lattice-sdk #45); between the alpha.9 pin and
it the SDK adds types the agent does not read and omits zero times from task
records it sends, which every server reads as before. No compatibility floor
moves: a server before S5a drops dns from the agent's probe methods and never
hands it a dns check, and a source on alpha.9 or older reads agent_too_old for
a dns check until it updates through its own approval.

What changes on a node:

- dns appears in probe_methods. The check's target scheme picks the transport:
https:// posts application/dns-message (RFC 8484), tls:// frames the message
behind a two-byte length inside TLS (RFC 7858, port 853 by default), and
udp:// and tcp:// are plain DNS (port 53). A truncated UDP answer is asked
again once over TCP, of the address that sent it.
- The resolver's host is resolved once and every address judged by the rule
http and tcp checks follow, so a resolver that resolves to a loopback,
link-local, metadata, multicast or private address is refused with
destination_refused before anything is sent. A DoH request goes only to the
judged addresses, through no proxy, and follows no redirect.
- One query per run, with recursion desired and EDNS(0) at 1232 bytes, to the
first of the resolver's addresses that answers: an address that cannot be
reached passes the question to the next within the timeout, over UDP so
does one that stays silent, and an answer, right or wrong, ends the run. The
check's timeout bounds the whole exchange, a DoH body over 64 KiB is refused
unread, latency runs from the dial to the decoded answer, and a run
cancelled at shutdown ends at once rather than at its timeout.
- DoH and DoT verify the resolver's certificate against its host name and
report its expiry, which the control plane lists under Upcoming.
- A failed run's error starts with dns_rcode, dns_mismatch, dns_no_answer or
dns_transport, and the result carries the response code and at most 16
answers of at most 255 bytes. The error is at most 512 bytes and quotes the
resolver's own text only in short pieces: a content type of at most 64
bytes, at most three certificate names of 64 bytes each, a transport error
of at most 160 bytes.

## 0.3.10-alpha.9 (2026-10-24)

Security hardening found when CI's gosec step, which had been scanning no
Expand Down
6 changes: 4 additions & 2 deletions cmd/lattice-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import (
"github.com/LatticeNet/lattice-sdk/model"
)

var version = "0.3.10-alpha.9"
var version = "0.3.10-alpha.10"
var compatServerMin = "v0.2.2-alpha.19"
var compatDashboardMin = "v0.2.2-alpha.7"
var compatChannel = "alpha"
Expand Down Expand Up @@ -783,7 +783,9 @@ func monitorChanged(a, b model.Monitor) bool {
return a.Type != b.Type || a.Target != b.Target || a.TargetNode != b.TargetNode ||
a.IntervalSec != b.IntervalSec || a.TimeoutSec != b.TimeoutSec ||
a.Packets != b.Packets || a.ExpectStatus != b.ExpectStatus ||
a.SecretsOrigin != b.SecretsOrigin || !slices.Equal(a.Headers, b.Headers)
a.SecretsOrigin != b.SecretsOrigin || !slices.Equal(a.Headers, b.Headers) ||
a.DNSName != b.DNSName || a.DNSType != b.DNSType || a.DNSExpectRcode != b.DNSExpectRcode ||
!slices.Equal(a.DNSExpect, b.DNSExpect)
}

// probe runs one probe and queues its result; flushLoop sends it, in a batch
Expand Down
4 changes: 2 additions & 2 deletions cmd/lattice-agent/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ func (r *countingTaskRunner) Run(task model.Task) model.TaskResult {
// node look like it never took the update it just took. Pinning it here means
// the bump is a deliberate edit rather than something that drifts.
func TestVersionMatchesCurrentRelease(t *testing.T) {
if version != "0.3.10-alpha.9" {
t.Fatalf("version = %q, want 0.3.10-alpha.9", version)
if version != "0.3.10-alpha.10" {
t.Fatalf("version = %q, want 0.3.10-alpha.10", version)
}
}

Expand Down
10 changes: 7 additions & 3 deletions cmd/lattice-agent/probe_facts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestHelloAndHeartbeatCarryProbeFacts(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
for _, kind := range []string{model.MonitorTypeTCP, model.MonitorTypeHTTP} {
for _, kind := range []string{model.MonitorTypeTCP, model.MonitorTypeHTTP, model.MonitorTypeDNS, model.MonitorTypeBeacon} {
if !slices.Contains(got.ProbeMethods, kind) {
t.Fatalf("%s probe_methods %v lacks %s", name, got.ProbeMethods, kind)
}
Expand All @@ -70,8 +70,12 @@ func TestMonitorChangedCoversProbeFields(t *testing.T) {
"header value": func(m *model.Monitor) {
m.Headers = []model.MonitorHeader{{Name: "X-Api-Key", Value: "two", Secret: true}}
},
"header removed": func(m *model.Monitor) { m.Headers = nil },
"secrets_origin": func(m *model.Monitor) { m.SecretsOrigin = "https://b.example:443" },
"header removed": func(m *model.Monitor) { m.Headers = nil },
"secrets_origin": func(m *model.Monitor) { m.SecretsOrigin = "https://b.example:443" },
"dns_name": func(m *model.Monitor) { m.DNSName = "example.net" },
"dns_type": func(m *model.Monitor) { m.DNSType = model.DNSTypeAAAA },
"dns_expect": func(m *model.Monitor) { m.DNSExpect = []string{"192.0.2.1"} },
"dns_expect_rcode": func(m *model.Monitor) { m.DNSExpectRcode = model.DNSRcodeNXDomain },
} {
changed := base
changed.Headers = slices.Clone(base.Headers)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.26.0

toolchain go1.26.10

require github.com/LatticeNet/lattice-sdk v0.2.24-0.20261017194014-a3de5f71b589
require github.com/LatticeNet/lattice-sdk v0.2.24-0.20261025003326-6485e4abe7b9

require github.com/creack/pty v1.1.24

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/LatticeNet/lattice-sdk v0.2.24-0.20261017194014-a3de5f71b589 h1:B7EVUKUz5Bj9gKIt7H7n3YJfTBR0nGz5op6qMnoZEJE=
github.com/LatticeNet/lattice-sdk v0.2.24-0.20261017194014-a3de5f71b589/go.mod h1:jx9PFwMH2zT993VRwecQZBo/rP9W1lgWcZYDdqr5D9M=
github.com/LatticeNet/lattice-sdk v0.2.24-0.20261025003326-6485e4abe7b9 h1:aKA/XfkcXfbYbGKSETmqwAUDVJ+EZMC68Oy8TMDgnG8=
github.com/LatticeNet/lattice-sdk v0.2.24-0.20261025003326-6485e4abe7b9/go.mod h1:jx9PFwMH2zT993VRwecQZBo/rP9W1lgWcZYDdqr5D9M=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
Expand Down
40 changes: 24 additions & 16 deletions internal/prober/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,27 +83,35 @@ func asciiHost(host string) (string, error) {
// net.Dialer gives each address of a serial fallback.
const minDialSlice = 2 * time.Second

// dialJudged connects to the first of addrs that answers on port, in the
// order the resolver gave them, sharing the time left between the
// addresses not yet tried as net.Dialer does. addrs are IP literals, so the
// dial makes no lookup.
func (p *prober) dialJudged(ctx context.Context, addrs []netip.Addr, port string) (net.Conn, error) {
// addrContext is the share of ctx's time one address gets while left
// addresses, itself included, remain to be tried: an equal share of what is
// left, at least minDialSlice (or all of it when less remains), and all of
// it for the last address.
func addrContext(ctx context.Context, left int) (context.Context, context.CancelFunc) {
if left > 1 {
if deadline, ok := ctx.Deadline(); ok {
slice := time.Until(deadline) / time.Duration(left)
if slice < minDialSlice {
slice = min(minDialSlice, time.Until(deadline))
}
return context.WithTimeout(ctx, slice)
}
}
return ctx, func() {}
}

// dialJudged connects over network (tcp or udp) to the first of addrs that
// answers on port, in the order the resolver gave them, sharing the time
// left between the addresses not yet tried as net.Dialer does. addrs are
// IP literals, so the dial makes no lookup.
func (p *prober) dialJudged(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
if len(addrs) == 0 {
return nil, errors.New("no address to dial")
}
var firstErr error
for i, addr := range addrs {
dctx, cancel := ctx, context.CancelFunc(func() {})
if left := len(addrs) - i; left > 1 {
if deadline, ok := ctx.Deadline(); ok {
slice := time.Until(deadline) / time.Duration(left)
if slice < minDialSlice {
slice = min(minDialSlice, time.Until(deadline))
}
dctx, cancel = context.WithTimeout(ctx, slice)
}
}
conn, err := p.dial(dctx, "tcp", net.JoinHostPort(addr.String(), port))
dctx, cancel := addrContext(ctx, len(addrs)-i)
conn, err := p.dial(dctx, network, net.JoinHostPort(addr.String(), port))
cancel()
if err == nil {
return conn, nil
Expand Down
Loading
Loading