From faac102844e081b98593904bb339de72ea813a68 Mon Sep 17 00:00:00 2001 From: llogen Date: Thu, 2 Jul 2026 13:50:37 +0200 Subject: [PATCH] feat: agent and client verify dutctl versions The dutagent build version is baked from git tags via -ldflags and advertised on every RPC response header. Clients compare it against their own version: minor mismatch warns, major errors, pre-release/dev diffs warn. Replaces the explicit Version RPC. Signed-off-by: llogen --- .github/workflows/release.yml | 10 +- .golangci.yml | 1 + .goreleaser.yaml | 6 +- cmds/dutagent/dutagent.go | 6 +- cmds/dutctl/dutctl.go | 6 ++ go.mod | 1 + go.sum | 2 + internal/buildinfo/buildinfo.go | 46 ++++++++- internal/buildinfo/version.go | 12 ++- pkg/versioncheck/interceptor.go | 139 ++++++++++++++++++++++++++ pkg/versioncheck/versioncheck.go | 96 ++++++++++++++++++ pkg/versioncheck/versioncheck_test.go | 138 +++++++++++++++++++++++++ 12 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 pkg/versioncheck/interceptor.go create mode 100644 pkg/versioncheck/versioncheck.go create mode 100644 pkg/versioncheck/versioncheck_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41d2291..aa6c203 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,23 +35,23 @@ jobs: - name: Build dutctl for amd64 if: ${{ steps.release.outputs.release_created }} - run: GOOS=linux GOARCH=amd64 go build -o dutctl-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/dutctl + run: GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutctl-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/dutctl - name: Build dutagent for amd64 if: ${{ steps.release.outputs.release_created }} - run: GOOS=linux GOARCH=amd64 go build -o dutagent-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/dutagent + run: GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutagent-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/dutagent - name: Build dutagent for arm64 if: ${{ steps.release.outputs.release_created }} - run: GOOS=linux GOARCH=arm64 go build -o dutagent-${{ steps.release.outputs.tag_name }}-linux-arm64 ./cmds/dutagent + run: GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutagent-${{ steps.release.outputs.tag_name }}-linux-arm64 ./cmds/dutagent - name: Build dutserver for amd64 if: ${{ steps.release.outputs.release_created }} - run: GOOS=linux GOARCH=amd64 go build -o dutserver-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/exp/dutserver + run: GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutserver-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/exp/dutserver - name: Build dutserver for arm64 if: ${{ steps.release.outputs.release_created }} - run: GOOS=linux GOARCH=arm64 go build -o dutserver-${{ steps.release.outputs.tag_name }}-linux-arm64 ./cmds/exp/dutserver + run: GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutserver-${{ steps.release.outputs.tag_name }}-linux-arm64 ./cmds/exp/dutserver - name: Upload dutctl-linux-amd64 diff --git a/.golangci.yml b/.golangci.yml index ac2aa34..5fcec68 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -165,6 +165,7 @@ linters: - Formatter - gpio - port + - Interceptor lll: line-length: 140 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8d9c38e..95e9202 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -14,7 +14,7 @@ builds: main: ./cmds/dutctl binary: "dutctl" ldflags: - - -s -w -X main.builtBy=goreleaser + - -s -w -X main.builtBy=goreleaser -X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion={{ .Version }} env: - CGO_ENABLED=0 goos: @@ -35,7 +35,7 @@ builds: main: ./cmds/dutagent binary: "dutagent" ldflags: - - -s -w -X main.builtBy=goreleaser + - -s -w -X main.builtBy=goreleaser -X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion={{ .Version }} env: - CGO_ENABLED=0 goos: @@ -56,7 +56,7 @@ builds: main: ./cmds/exp/dutserver binary: "dutserver" ldflags: - - -s -w -X main.builtBy=goreleaser + - -s -w -X main.builtBy=goreleaser -X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion={{ .Version }} env: - CGO_ENABLED=0 goos: diff --git a/cmds/dutagent/dutagent.go b/cmds/dutagent/dutagent.go index 631ee45..9a1bb19 100644 --- a/cmds/dutagent/dutagent.go +++ b/cmds/dutagent/dutagent.go @@ -24,6 +24,7 @@ import ( "github.com/BlindspotSoftware/dutctl/internal/buildinfo" "github.com/BlindspotSoftware/dutctl/internal/dutagent" "github.com/BlindspotSoftware/dutctl/pkg/dut" + "github.com/BlindspotSoftware/dutctl/pkg/versioncheck" "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect" "gopkg.in/yaml.v3" @@ -164,7 +165,10 @@ func (agt *agent) startRPCService() error { } mux := http.NewServeMux() - path, handler := dutctlv1connect.NewDeviceServiceHandler(service) + path, handler := dutctlv1connect.NewDeviceServiceHandler( + service, + connect.WithInterceptors(versioncheck.NewServerInterceptor(versioncheck.SelfVersion())), + ) mux.Handle(path, handler) // Serve HTTP/2 without TLS (h2c) diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index 8df6669..aaf6b8a 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -19,6 +19,7 @@ import ( "github.com/BlindspotSoftware/dutctl/internal/buildinfo" "github.com/BlindspotSoftware/dutctl/internal/output" "github.com/BlindspotSoftware/dutctl/pkg/lock" + "github.com/BlindspotSoftware/dutctl/pkg/versioncheck" "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect" ) @@ -152,6 +153,11 @@ func (app *application) setupRPCClient() { newInsecureClient(), fmt.Sprintf("http://%s", app.serverAddr), connect.WithGRPC(), + connect.WithInterceptors(versioncheck.NewClientInterceptor( + versioncheck.SelfVersion(), + func(msg string) { slog.Warn(msg) }, + nil, + )), ) app.rpcClient = client diff --git a/go.mod b/go.mod index 762dcdd..61f54b6 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/stianeikeland/go-rpio/v4 v4.6.0 go.bug.st/serial v1.7.1 golang.org/x/crypto v0.53.0 + golang.org/x/mod v0.36.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index e81e49b..91bb0b8 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc= go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index ce77708..d0910ce 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -24,6 +24,50 @@ func VersionString() string { return i.String() } +// Semver returns the best-known semantic version of the dutctl build. Release +// and CI builds stamp [buildVersion] via ldflags, so that wins. For binaries +// that were not stamped (plain `go build`) or for dutctl imported as a +// dependency (e.g. contest), it falls back to the module version recorded in +// the build info, and finally to the compiled-in [Version] default. +func Semver() string { + if buildVersion != "" { + return buildVersion + } + + if buildInfo, ok := debug.ReadBuildInfo(); ok { + if v := moduleVersion(buildInfo); v != "" { + return v + } + } + + return Version +} + +// moduleVersion returns the dutctl module version from the build info: the main +// module version when a dutctl binary is running via `go install pkg@version`, +// or the dutctl entry in Deps when dutctl is imported by another module. It +// returns "" for local (`go build`) and filesystem-replaced builds, which +// record no usable version — those fall back to the ldflags [Version]. +func moduleVersion(buildInfo *debug.BuildInfo) string { + const modulePath = "github.com/BlindspotSoftware/dutctl" + + if buildInfo.Main.Path == modulePath && isRealVersion(buildInfo.Main.Version) { + return buildInfo.Main.Version + } + + for _, dep := range buildInfo.Deps { + if dep.Path == modulePath && isRealVersion(dep.Version) { + return dep.Version + } + } + + return "" +} + +func isRealVersion(v string) bool { + return v != "" && v != "(devel)" +} + // info is a wrapper for [debug.BuildInfo]. type info struct { semver string // Semantic release version of the application. @@ -51,7 +95,7 @@ func (i *info) String() string { func (i *info) read() { const unknown = "unknown" - i.semver = Version + i.semver = Semver() if bi, ok := debug.ReadBuildInfo(); ok { i.revision = cvsShortHash(findSetting("vcs.revision", bi.Settings)) diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 4cffeb1..e0ba9c4 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -4,6 +4,14 @@ package buildinfo -// Version is the released semantic version of the application. -// It is bumped automatically by release-please on every release. +// Version is the released semantic version of the application, bumped +// automatically by release-please. It is the fallback used when no build-time +// version was injected (and when dutctl is imported as a dependency). const Version = "1.0.0-alpha.1" // x-release-please-version + +// buildVersion is the version stamped at build time via +// -ldflags "-X .../buildinfo.buildVersion=" by release and CI +// builds. It is deliberately empty by default so [Semver] can tell an injected +// build apart from a plain `go build` (whose VCS-derived module version would +// otherwise shadow the ldflags value). +var buildVersion string //nolint:gochecknoglobals // set via -ldflags at build time diff --git a/pkg/versioncheck/interceptor.go b/pkg/versioncheck/interceptor.go new file mode 100644 index 0000000..883c377 --- /dev/null +++ b/pkg/versioncheck/interceptor.go @@ -0,0 +1,139 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package versioncheck + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + + "connectrpc.com/connect" +) + +// NewServerInterceptor returns a connect interceptor that stamps the agent's +// version onto the response header of every RPC (unary and streaming), so +// clients always learn which agent build served them. +func NewServerInterceptor(version string) connect.Interceptor { + return &serverInterceptor{version: version} +} + +type serverInterceptor struct { + version string +} + +func (i *serverInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + res, err := next(ctx, req) + if res != nil { + res.Header().Set(HeaderName, i.version) + } + + return res, err + } +} + +func (i *serverInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return next +} + +func (i *serverInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + conn.ResponseHeader().Set(HeaderName, i.version) + + return next(ctx, conn) + } +} + +// NewClientInterceptor returns a connect interceptor that compares the agent +// version advertised on each response (via HeaderName) against self. On a Warn +// it calls onWarn once per connection; on an Error it fails the call with a +// FailedPrecondition error. onWarn may be nil. +func NewClientInterceptor(self string, onWarn, onInfo func(string)) connect.Interceptor { + return &clientInterceptor{self: self, onWarn: onWarn, onInfo: onInfo} +} + +type clientInterceptor struct { + self string + onWarn func(string) + onInfo func(string) + + once sync.Once + result error +} + +// check classifies the peer version carried in header. It reports the observed +// version (onInfo) and any warning (onWarn) at most once per client, and returns +// — on every call — the cached error when the pairing is incompatible. +func (i *clientInterceptor) check(header http.Header) error { + i.once.Do(func() { + peer := header.Get(HeaderName) + + if i.onInfo != nil { + i.onInfo(fmt.Sprintf("dutagent version: %s", peer)) + } + + act, reason := classify(i.self, peer) + switch act { + case actionError: + i.result = connect.NewError(connect.CodeFailedPrecondition, errors.New(reason)) + case actionWarn: + if i.onWarn != nil && reason != "" { + i.onWarn(reason) + } + case actionOK: + } + }) + + return i.result +} + +func (i *clientInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + res, err := next(ctx, req) + if err != nil { + return res, err + } + + cerr := i.check(res.Header()) + if cerr != nil { + return nil, cerr + } + + return res, nil + } +} + +func (i *clientInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return next +} + +func (i *clientInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { + return &checkingClientConn{ + StreamingClientConn: next(ctx, spec), + interceptor: i, + } + } +} + +// checkingClientConn runs the version check after the first response is received +// (which is when the response header becomes available). The interceptor's own +// once guard makes the repeated calls cheap. +type checkingClientConn struct { + connect.StreamingClientConn + + interceptor *clientInterceptor +} + +func (c *checkingClientConn) Receive(msg any) error { + err := c.StreamingClientConn.Receive(msg) + if err != nil { + return err + } + + return c.interceptor.check(c.ResponseHeader()) +} diff --git a/pkg/versioncheck/versioncheck.go b/pkg/versioncheck/versioncheck.go new file mode 100644 index 0000000..2b037cb --- /dev/null +++ b/pkg/versioncheck/versioncheck.go @@ -0,0 +1,96 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package versioncheck compares the dutctl build versions of a client and an +// agent and decides whether their pairing is compatible. The agent advertises +// its version on every response via a header (see the interceptors in this +// package); the client classifies the difference against its own version: +// +// - differing major version -> error (incompatible, abort the call) +// - differing minor version -> warn +// - differing pre-release only -> warn +// - unparsable / dev versions -> warn (never error) +// - patch-only diff / identical -> ok (silent) +package versioncheck + +import ( + "fmt" + "strings" + + "github.com/BlindspotSoftware/dutctl/internal/buildinfo" + "golang.org/x/mod/semver" +) + +// HeaderName is the response header the agent uses to advertise its build +// version to clients on every RPC. +const HeaderName = "X-Dutctl-Version" + +// SelfVersion returns the dutctl build version of the current process, to pass +// as the version argument to [NewServerInterceptor] or the self argument to +// [NewClientInterceptor]. It is exported so external consumers of the dutctl +// module — which cannot import the internal buildinfo package — can obtain it. +func SelfVersion() string { + return buildinfo.Semver() +} + +// action is the outcome of comparing two versions. +type action int + +const ( + // actionOK means the versions are compatible; nothing to report. + actionOK action = iota + // actionWarn means the versions differ in a tolerated way; surface a warning. + actionWarn + // actionError means the versions are incompatible; the interaction must fail. + actionError +) + +// classify compares the client's own version (self) against the peer's +// advertised version and returns the action to take plus a human-readable +// reason (empty when the action is actionOK). +func classify(self, peer string) (action, string) { + selfVer := normalize(self) + peerVer := normalize(peer) + + if !semver.IsValid(selfVer) || !semver.IsValid(peerVer) { + return actionWarn, fmt.Sprintf("cannot compare dutctl versions (client %q, agent %q)", self, peer) + } + + if semver.Major(selfVer) != semver.Major(peerVer) { + return actionError, fmt.Sprintf("incompatible dutctl major version: client %s, agent %s", self, peer) + } + + if semver.MajorMinor(selfVer) != semver.MajorMinor(peerVer) { + return actionWarn, fmt.Sprintf("dutctl minor version mismatch: client %s, agent %s", self, peer) + } + + // Same major.minor. A differing patch is tolerated silently; a difference + // only in the pre-release tag (e.g. 1.0.0-alpha.1 vs 1.0.0-alpha.5) warns. + if core(selfVer) == core(peerVer) && semver.Compare(selfVer, peerVer) != 0 { + return actionWarn, fmt.Sprintf("dutctl pre-release version mismatch: client %s, agent %s", self, peer) + } + + return actionOK, "" +} + +// normalize trims the value and ensures the leading "v" that +// golang.org/x/mod/semver requires. +func normalize(version string) string { + version = strings.TrimSpace(version) + if version == "" || strings.HasPrefix(version, "v") { + return version + } + + return "v" + version +} + +// core returns the vMAJOR.MINOR.PATCH portion of a canonical semver, dropping +// any pre-release and build metadata. +func core(version string) string { + version = semver.Canonical(version) + version = strings.TrimSuffix(version, semver.Build(version)) + version = strings.TrimSuffix(version, semver.Prerelease(version)) + + return version +} diff --git a/pkg/versioncheck/versioncheck_test.go b/pkg/versioncheck/versioncheck_test.go new file mode 100644 index 0000000..8803c68 --- /dev/null +++ b/pkg/versioncheck/versioncheck_test.go @@ -0,0 +1,138 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package versioncheck + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "connectrpc.com/connect" + + pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" + "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect" +) + +func TestClassify(t *testing.T) { + cases := []struct { + name string + self, peer string + want action + }{ + {"identical", "1.0.0-alpha.1", "1.0.0-alpha.1", actionOK}, + {"identical with v", "v1.2.3", "v1.2.3", actionOK}, + {"patch differs", "1.0.0", "1.0.1", actionOK}, + {"minor differs", "1.1.0", "1.0.0", actionWarn}, + {"major differs", "2.0.0", "1.9.9", actionError}, + {"prerelease differs", "1.0.0-alpha.1", "1.0.0-alpha.5", actionWarn}, + {"client dev", "devel", "1.0.0", actionWarn}, + {"agent empty", "1.0.0", "", actionWarn}, + {"both garbage", "nope", "also-nope", actionWarn}, + {"git describe ahead of tag", "v1.0.0-alpha.1-5-gabc123", "v1.0.0-alpha.1", actionWarn}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, reason := classify(tc.self, tc.peer) + if got != tc.want { + t.Errorf("classify(%q, %q) = %v (%q), want %v", tc.self, tc.peer, got, reason, tc.want) + } + if got != actionOK && reason == "" { + t.Errorf("classify(%q, %q) returned non-OK action with empty reason", tc.self, tc.peer) + } + }) + } +} + +// stubService is a minimal DeviceService handler; only List is used. +type stubService struct { + dutctlv1connect.UnimplementedDeviceServiceHandler +} + +func (stubService) List( + _ context.Context, _ *connect.Request[pb.ListRequest], +) (*connect.Response[pb.ListResponse], error) { + return connect.NewResponse(&pb.ListResponse{}), nil +} + +func newAgent(t *testing.T, agentVersion string) string { + t.Helper() + + mux := http.NewServeMux() + path, handler := dutctlv1connect.NewDeviceServiceHandler( + stubService{}, + connect.WithInterceptors(NewServerInterceptor(agentVersion)), + ) + mux.Handle(path, handler) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return srv.URL +} + +func TestClientInterceptor(t *testing.T) { + url := newAgent(t, "1.2.0") + + call := func(self string) (warned, infoed string, err error) { + client := dutctlv1connect.NewDeviceServiceClient( + http.DefaultClient, url, + connect.WithInterceptors(NewClientInterceptor( + self, + func(m string) { warned = m }, + func(m string) { infoed = m }, + )), + ) + _, err = client.List(context.Background(), connect.NewRequest(&pb.ListRequest{})) + + return warned, infoed, err + } + + t.Run("compatible: info only, no warn, no error", func(t *testing.T) { + warned, infoed, err := call("1.2.9") // patch diff only + if err != nil { + t.Fatalf("List: unexpected error: %v", err) + } + if warned != "" { + t.Errorf("unexpected warning: %q", warned) + } + if infoed == "" { + t.Error("expected the observed agent version to be reported via onInfo") + } + }) + + t.Run("minor mismatch: warns, succeeds", func(t *testing.T) { + warned, _, err := call("1.5.0") + if err != nil { + t.Fatalf("List: unexpected error: %v", err) + } + if warned == "" { + t.Error("expected a warning for a minor mismatch") + } + }) + + t.Run("major mismatch: errors", func(t *testing.T) { + _, _, err := call("2.0.0") + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("code = %v, want FailedPrecondition", connect.CodeOf(err)) + } + }) +} + +func TestServerInterceptorSetsHeader(t *testing.T) { + url := newAgent(t, "9.9.9") + + client := dutctlv1connect.NewDeviceServiceClient(http.DefaultClient, url) + + res, err := client.List(context.Background(), connect.NewRequest(&pb.ListRequest{})) + if err != nil { + t.Fatalf("List: %v", err) + } + + if got := res.Header().Get(HeaderName); got != "9.9.9" { + t.Errorf("%s header = %q, want 9.9.9", HeaderName, got) + } +}