Skip to content
Open
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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,22 @@ Download the [latest release](https://github.com/BlindspotSoftware/dutctl/releas

1. **Start the DUT Agent**
```bash
dutagent -a localhost:1024 -c ./contrib/dutagent-cfg-example.yaml
dutagent -a localhost:1024 -c ./contrib/dutagent-cfg-example.yaml -insecure
```
Run the DUT Agent locally with an example configuration in a separate terminal session.
This test configuration does not require a connected DUT.

`-insecure` keeps this walkthrough to plain HTTP. Without it the agent serves TLS and
writes a self-signed key pair to `/var/lib/dutagent/tls/`, which a normal user account
cannot do — pass `-tls-cert ./cert.pem -tls-key ./key.pem` to put it somewhere writable
instead. See [Transport Security](./docs/README.md#transport-security).

2. **Play around with the DUT Client**
```bash
# dutctl connect to localhost:1024 by default.
# dutctl connects to localhost:1024 by default.
# Match the agent's transport: -insecure here, because the agent above uses it.
# Use 'list' to see the available devices that are managed be the agent:
dutctl list
dutctl list -insecure

# You can discover the available commands per device and run them.
# Check out the usage information to learn how:
Expand Down
87 changes: 84 additions & 3 deletions cmds/dutagent/dutagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package main

import (
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
Expand All @@ -26,6 +27,7 @@ import (
"github.com/BlindspotSoftware/dutctl/internal/dutagent/locker"
"github.com/BlindspotSoftware/dutctl/internal/log"
"github.com/BlindspotSoftware/dutctl/internal/rpc"
"github.com/BlindspotSoftware/dutctl/internal/tlsutil"
"github.com/BlindspotSoftware/dutctl/pkg/dut"
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
"gopkg.in/yaml.v3"
Expand All @@ -42,6 +44,19 @@ const (
versionFlagInfo = `Print version information and exit`
logLevelInfo = `Log level: debug, info, warn, or error`
logJSONInfo = `Emit logs as JSON instead of human-readable text`
insecureInfo = `Disable TLS and serve plain HTTP/2 cleartext (h2c); clients must match`
tlsCertInfo = `Path to the TLS certificate file; a self-signed pair is generated if neither it nor the key exists`
tlsKeyInfo = `Path to the TLS key file; a self-signed pair is generated if neither it nor the certificate exists`
)

// Default locations of the agent's TLS key pair. Both are generated on first
// start if neither exists — see internal/tlsutil. They live under /var/lib
// rather than /etc because the packaged unit runs unprivileged with
// ProtectSystem=strict, which makes /etc read-only; /var/lib/dutagent is the
// one path it may write (see packaging/dutagent.service).
const (
defaultTLSCertPath = "/var/lib/dutagent/tls/cert.pem"
defaultTLSKeyPath = "/var/lib/dutagent/tls/key.pem"
)

func newAgent(stdout io.Writer, exitFunc func(int), args []string) *agent {
Expand All @@ -59,6 +74,9 @@ func newAgent(stdout io.Writer, exitFunc func(int), args []string) *agent {
fs.BoolVar(&agt.versionFlag, "v", false, versionFlagInfo)
fs.StringVar(&agt.logLevel, "log", "debug", logLevelInfo)
fs.BoolVar(&agt.logJSON, "log-json", false, logJSONInfo)
fs.BoolVar(&agt.insecure, "insecure", false, insecureInfo)
fs.StringVar(&agt.tlsCertPath, "tls-cert", defaultTLSCertPath, tlsCertInfo)
fs.StringVar(&agt.tlsKeyPath, "tls-key", defaultTLSKeyPath, tlsKeyInfo)
//nolint:errcheck // flag.Parse always returns no error because of flag.ExitOnError
fs.Parse(args[1:])

Expand All @@ -79,10 +97,27 @@ type agent struct {
server string
logLevel string
logJSON bool
insecure bool
tlsCertPath string
tlsKeyPath string

// state
config config
modulesNeedDeinit bool
// cert is the key pair the RPC service serves with, resolved by loadTLSCert
// before module initialization. Unset under -insecure.
cert tls.Certificate
}

// security maps the -insecure flag to the transport the agent dials the
// dutserver with. The serving side branches on agt.insecure directly, since it
// needs the certificate too and not just the transport.
func (agt *agent) security() rpc.Security {
if agt.insecure {
return rpc.Insecure
}

return rpc.TLS
}

// config holds the dutagent configuration that is parsed from YAML data.
Expand Down Expand Up @@ -167,10 +202,36 @@ func printInitErr(err error) {
slog.Error("module error", "err", err)
}

// loadTLSCert resolves the key pair the RPC service will serve with: it loads
// -tls-cert/-tls-key, generating a self-signed pair if neither file exists yet.
// It is a no-op under -insecure. See internal/tlsutil for what the generated
// pair does and does not protect.
func (agt *agent) loadTLSCert() error {
if agt.insecure {
return nil
}

cert, generated, err := tlsutil.LoadOrGenerateCert(agt.tlsCertPath, agt.tlsKeyPath)
if err != nil {
return fmt.Errorf("loading TLS certificate: %w", err)
}

if generated {
slog.Info("generated self-signed TLS certificate", "cert", agt.tlsCertPath, "key", agt.tlsKeyPath)
}

agt.cert = cert

return nil
}

// startRPCService starts the RPC service and serves until ctx is cancelled (a
// signal), draining in-flight requests, or until the server stops on its own. It
// returns the server error, if any; the caller classifies a graceful stop via
// ctx.Err().
//
// The service is served over TLS unless -insecure was given, using the key pair
// loadTLSCert already resolved.
func (agt *agent) startRPCService(ctx context.Context) error {
service := &rpcService{
devices: agt.config.Devices,
Expand All @@ -187,15 +248,24 @@ func (agt *agent) startRPCService(ctx context.Context) error {
)
mux.Handle(path, handler)

slog.Info("rpc service listening", "addr", agt.address)
if agt.insecure {
slog.Warn("rpc service listening WITHOUT TLS", "addr", agt.address)

return rpc.ListenAndServe(ctx, agt.address, mux)
return rpc.ListenAndServe(ctx, agt.address, mux)
}

slog.Info("rpc service listening with TLS", "addr", agt.address, "cert", agt.tlsCertPath)

return rpc.ListenAndServeTLS(ctx, agt.address, mux, agt.cert)
}

func (agt *agent) registerWithServer() error {
slog.Info("registering with server", "server", agt.server)

client := rpc.NewRelayClient(agt.server)
// The registration RPC follows the agent's own transport setting. dutserver
// takes the same -insecure flag, so a deployment runs all three binaries on
// one setting; mixing them fails here at connect time.
client := rpc.NewRelayClient(agt.server, agt.security())
req := connect.NewRequest(&pb.RegisterRequest{
Devices: agt.config.Devices.Names(),
Address: agt.address,
Expand Down Expand Up @@ -261,6 +331,17 @@ func (agt *agent) start() {
agt.cleanup(exit1)
}

// Resolve the TLS key pair before touching any hardware. Doing it here rather
// than at listen time means an unwritable certificate directory fails startup
// cleanly, instead of after initModules has already driven the DUTs — and it
// puts the check inside the -dry-run path, which exists to catch exactly this
// kind of misconfiguration.
err = agt.loadTLSCert()
if err != nil {
slog.Error("loading TLS certificate failed", "err", err)
agt.cleanup(exit1)
}

// initCtx carries module initialization; it flows into every module's Init via
// internal/log. It derives from the signal context so Ctrl-C interrupts a slow
// startup, and is bounded by initTimeout so a wedged Init fails startup instead
Expand Down
49 changes: 46 additions & 3 deletions cmds/dutctl/dutctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"log/slog"
"os"
"os/signal"
"strings"
"syscall"

"connectrpc.com/connect"
Expand Down Expand Up @@ -57,6 +58,10 @@ releases it; add the force keyword to release a lock held by another user.
Locks are advisory, so reserve a device only as long as you need it.

When dutctl is run without any positional arguments, it defaults to the list command.

dutctl talks to the agent over TLS by default. The agent's certificate is not
verified, so the connection is encrypted but not authenticated. Use -insecure to
talk to an agent that was started with -insecure; both ends must agree.
`

// Usage strings for the command-line flags, shown in the OPTIONS section of dutctl -h.
Expand All @@ -67,6 +72,7 @@ const (
noColorUsage = `Disable colored output`
userUsage = `User Identity of the user of the device, defaults to <user>@<host>`
logUsage = `Client-side diagnostic logging (on stderr), debug|warn|none, default is warn`
insecureUsage = `Disable TLS and talk plain HTTP/2 cleartext (h2c); for an agent started with -insecure`
)

func newApp(stdin io.Reader, stdout, stderr io.Writer, exitFunc func(int), args []string) *application {
Expand Down Expand Up @@ -94,6 +100,7 @@ func newApp(stdin io.Reader, stdout, stderr io.Writer, exitFunc func(int), args
fs.BoolVar(&app.verbose, "v", false, verboseUsage)
fs.BoolVar(&app.noColor, "no-color", false, noColorUsage)
fs.StringVar(&app.user, "u", auth.Default().User(), userUsage)
fs.BoolVar(&app.insecure, "insecure", false, insecureUsage)

mode := logModeWarn
fs.Var(&mode, "log", logUsage)
Expand Down Expand Up @@ -136,6 +143,7 @@ type application struct {
verbose bool
noColor bool
user string
insecure bool
args []string
printFlagDefaults func()

Expand All @@ -150,8 +158,14 @@ type application struct {
}

func (app *application) setupRPCClient() {
sec := rpc.TLS
if app.insecure {
sec = rpc.Insecure
}

app.rpcClient = rpc.NewDeviceClient(
app.serverAddr,
sec,
connect.WithInterceptors(rpc.NewVersionAdvisor(buildinfo.Version)),
)
}
Expand Down Expand Up @@ -316,7 +330,7 @@ func (app *application) exit(err error) {
// Render the terminating error through the formatter (stderr, format-aware).
app.formatter.WriteContent(output.Content{
Type: output.TypeGeneral,
Data: userFacingError(err, app.serverAddr),
Data: userFacingError(err, app.serverAddr, app.insecure),
IsError: true,
})

Expand All @@ -334,19 +348,48 @@ func (app *application) exit(err error) {
// gives the common "agent unreachable" case a friendlier line; other errors —
// including client-side ones like a bad command line or a missing local file —
// render unchanged.
func userFacingError(err error, serverAddr string) string {
func userFacingError(err error, serverAddr string, insecure bool) string {
var connErr *connect.Error
if !errors.As(err, &connErr) {
return err.Error()
}

if connErr.Code() == connect.CodeUnavailable {
return fmt.Sprintf("cannot reach dutagent at %s (%s)", serverAddr, connErr.Message())
msg := fmt.Sprintf("cannot reach dutagent at %s (%s)", serverAddr, connErr.Message())

if hint := transportHint(connErr.Message(), insecure); hint != "" {
msg += "\n" + hint
}

return msg
}

return connErr.Message()
}

// transportHint names the fix for a client/agent transport mismatch, which is
// otherwise near-undiagnosable from the client side. The mismatch happens below
// the RPC layer, so the version interceptors never exchange headers and cannot
// report it; all the user gets is a dial failure whose message ("unexpected EOF")
// says nothing about TLS. It matches on the transport's message text because
// neither side surfaces a typed error for this.
func transportHint(msg string, insecure bool) string {
// A cleartext client reaching a TLS agent: the agent drops the connection on
// a request that is not a TLS handshake, which surfaces here as a truncated
// read.
if insecure && strings.Contains(msg, "unexpected EOF") {
return "hint: the agent may be serving TLS; retry without -insecure"
}

// A TLS client reaching a cleartext agent: Go's transport recognises the
// plain HTTP response and says so.
if !insecure && strings.Contains(msg, "server gave HTTP response to HTTPS client") {
return "hint: the agent is serving cleartext; retry with -insecure, or upgrade the agent"
}

return ""
}

func (app *application) printVersion() {
app.formatter.WriteContent(output.Content{
Type: output.TypeVersion,
Expand Down
31 changes: 27 additions & 4 deletions cmds/dutctl/errmsg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import (
// TestUserFacingError verifies error rendering: a non-connect error passes through
// unchanged, a connect error drops its "code:" prefix (via Message), and
// CodeUnavailable gets the friendly "cannot reach" line naming the server address.
// A transport mismatch additionally gets a hint naming the fix, since it fails
// below the RPC layer and the message alone does not mention TLS.
func TestUserFacingError(t *testing.T) {
const addr = "host:1234"

tests := []struct {
name string
err error
want string
name string
err error
insecure bool
want string
}{
{
name: "non-connect error unchanged",
Expand All @@ -37,11 +40,31 @@ func TestUserFacingError(t *testing.T) {
err: connect.NewError(connect.CodeUnavailable, errors.New("connection refused")),
want: "cannot reach dutagent at host:1234 (connection refused)",
},
{
name: "cleartext client against TLS agent gets a hint",
err: connect.NewError(connect.CodeUnavailable, errors.New("unexpected EOF")),
insecure: true,
want: "cannot reach dutagent at host:1234 (unexpected EOF)\n" +
"hint: the agent may be serving TLS; retry without -insecure",
},
{
name: "TLS client against cleartext agent gets a hint",
err: connect.NewError(connect.CodeUnavailable,
errors.New("http: server gave HTTP response to HTTPS client")),
want: "cannot reach dutagent at host:1234 (http: server gave HTTP response to HTTPS client)\n" +
"hint: the agent is serving cleartext; retry with -insecure, or upgrade the agent",
},
{
name: "no hint when the mismatch marker belongs to the other direction",
err: connect.NewError(connect.CodeUnavailable, errors.New("unexpected EOF")),
insecure: false,
want: "cannot reach dutagent at host:1234 (unexpected EOF)",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := userFacingError(tt.err, addr); got != tt.want {
if got := userFacingError(tt.err, addr, tt.insecure); got != tt.want {
t.Errorf("userFacingError = %q, want %q", got, tt.want)
}
})
Expand Down
Loading