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
47 changes: 25 additions & 22 deletions cmds/dutagent/dutagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,22 @@ package main

import (
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"connectrpc.com/connect"
"github.com/BlindspotSoftware/dutctl/internal/buildinfo"
"github.com/BlindspotSoftware/dutctl/internal/dutagent"
"github.com/BlindspotSoftware/dutctl/pkg/dut"
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"gopkg.in/yaml.v3"

pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1"
Expand Down Expand Up @@ -155,6 +152,9 @@ func printInitErr(err error) {
log.Print(err)
}

// readHeaderTimeout bounds how long the server waits to read request headers.
const readHeaderTimeout = 10 * time.Second

// startRPCService starts the RPC service, that ideally listens for incoming
// connections forever. It always returns an non-nil error.
func (agt *agent) startRPCService() error {
Expand All @@ -167,12 +167,17 @@ func (agt *agent) startRPCService() error {
path, handler := dutctlv1connect.NewDeviceServiceHandler(service)
mux.Handle(path, handler)

//nolint:gosec
return http.ListenAndServe(
agt.address,
// Use h2c so we can serve HTTP/2 without TLS.
h2c.NewHandler(mux, &http2.Server{}),
)
// Serve HTTP/2 without TLS (h2c)
srv := &http.Server{
Addr: agt.address,
Handler: mux,
ReadHeaderTimeout: readHeaderTimeout,
}
srv.Protocols = new(http.Protocols)
srv.Protocols.SetHTTP1(true)
srv.Protocols.SetUnencryptedHTTP2(true)

return srv.ListenAndServe()
}

func (agt *agent) registerWithServer() error {
Expand Down Expand Up @@ -211,19 +216,17 @@ func spawnClient(agendURL string) dutctlv1connect.RelayServiceClient {

// TODO: refactor into pkg and reuse in dutctl and dutserver.
func newInsecureClient() *http.Client {
transport := &http.Transport{}
transport.Protocols = new(http.Protocols)
transport.Protocols.SetUnencryptedHTTP2(true)

return &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) {
// If you're also using this client for non-h2c traffic, you may want
// to delegate to tls.Dial if the network isn't TCP or the addr isn't
// in an allowlist.

//nolint:noctx
return net.Dial(network, addr)
},
// TODO: Don't forget timeouts!
},
Transport: transport,
// TODO: Don't forget timeouts! http.Client.Timeout must not be used here:
// it bounds the entire exchange including the response body, which would
// abort long-lived streaming RPCs. Instead use per-RPC context deadlines
// on unary calls and/or transport timeouts (DialContext,
// TLSHandshakeTimeout, ResponseHeaderTimeout, IdleConnTimeout).
}
}

Expand Down
27 changes: 11 additions & 16 deletions cmds/dutctl/dutctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@
package main

import (
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"

Expand All @@ -22,7 +20,6 @@ import (
"github.com/BlindspotSoftware/dutctl/internal/output"
"github.com/BlindspotSoftware/dutctl/pkg/lock"
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
"golang.org/x/net/http2"
)

const usageAbstract = `dutctl - The client application of the DUT Control system.
Expand Down Expand Up @@ -128,7 +125,6 @@ type application struct {

func (app *application) setupRPCClient() {
client := dutctlv1connect.NewDeviceServiceClient(
// Instead of http.DefaultClient, use the HTTP/2 protocol without TLS
newInsecureClient(),
fmt.Sprintf("http://%s", app.serverAddr),
connect.WithGRPC(),
Expand All @@ -138,19 +134,18 @@ func (app *application) setupRPCClient() {
}

func newInsecureClient() *http.Client {
// Use the HTTP/2 protocol without TLS (h2c)
transport := &http.Transport{}
transport.Protocols = new(http.Protocols)
transport.Protocols.SetUnencryptedHTTP2(true)

return &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) {
// If you're also using this client for non-h2c traffic, you may want
// to delegate to tls.Dial if the network isn't TCP or the addr isn't
// in an allowlist.

//nolint:noctx
return net.Dial(network, addr)
},
// Don't forget timeouts!
},
Transport: transport,
// TODO: Don't forget timeouts! http.Client.Timeout must not be used here:
// it bounds the entire exchange including the response body, which would
// abort long-lived streaming RPCs. Instead use per-RPC context deadlines
// on unary calls and/or transport timeouts (DialContext,
// TLSHandshakeTimeout, ResponseHeaderTimeout, IdleConnTimeout).
}
}

Expand Down
60 changes: 60 additions & 0 deletions cmds/dutctl/rawconsole_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 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 main

import (
"bytes"
"testing"
)

// TestRawConsoleNeverArmsForNonFileStdin verifies that a non-*os.File stdin
// (e.g. a piped/scripted run) never switches to raw mode, regardless of the
// interactive hint, and that arm/disarm are safe no-ops in that case.
func TestRawConsoleNeverArmsForNonFileStdin(t *testing.T) {
console := newRawConsole(&bytes.Buffer{}, true)

console.arm()

if console.isActive() {
t.Error("isActive() = true for non-file stdin, want false")
}

// disarm must not panic even though arm never engaged.
console.disarm()
}

// TestRawConsoleNeverArmsForScriptedInvocation verifies that when the command
// was invoked with arguments (interactive=false), raw mode is never armed even
// though the agent streams console output (modelled here by calling arm).
func TestRawConsoleNeverArmsForScriptedInvocation(t *testing.T) {
// interactive=false models a serial expect/send sequence run.
console := newRawConsole(&bytes.Buffer{}, false)

console.arm()

if console.isActive() {
t.Error("isActive() = true for a scripted (argument-bearing) invocation, want false")
}

console.disarm()
}

// TestRawConsoleArmIsIdempotent verifies that repeated arm calls (one per
// console message) do not panic and leave a consistent state. With a non-file
// stdin it stays inactive; the point is that calling arm many times is safe.
func TestRawConsoleArmIsIdempotent(t *testing.T) {
console := newRawConsole(&bytes.Buffer{}, true)

for range 5 {
console.arm()
}

if console.isActive() {
t.Error("isActive() = true for non-file stdin, want false")
}

console.disarm()
console.disarm() // double disarm must be safe
}
15 changes: 15 additions & 0 deletions cmds/dutctl/rawmode_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2025 Blindspot Software
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build darwin

package main

import "golang.org/x/sys/unix"

// Terminal get/set ioctl request numbers for macOS (BSD-derived).
const (
tcGetReq = unix.TIOCGETA
tcSetReq = unix.TIOCSETA
)
15 changes: 15 additions & 0 deletions cmds/dutctl/rawmode_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2025 Blindspot Software
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build linux

package main

import "golang.org/x/sys/unix"

// Terminal get/set ioctl request numbers for Linux.
const (
tcGetReq = unix.TCGETS
tcSetReq = unix.TCSETS
)
14 changes: 14 additions & 0 deletions cmds/dutctl/rawmode_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright 2025 Blindspot Software
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build !linux && !darwin

package main

// setRawInput is a no-op on platforms without termios support (e.g. Windows).
// Input stays line-buffered; the interactive serial experience is degraded but
// the client still builds and runs.
func setRawInput(_ int) func() {
return nil
}
53 changes: 53 additions & 0 deletions cmds/dutctl/rawmode_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2025 Blindspot Software
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build linux || darwin

package main

import "golang.org/x/sys/unix"

// setRawInput puts the terminal into raw input mode so the interactive serial
// session behaves like a direct console:
//
// - ECHO/ICANON off: keystrokes are delivered immediately, character at a
// time, and not echoed locally (the DUT echoes them back).
// - ISIG off: control characters such as Ctrl-C, Ctrl-Z and Ctrl-\ are NOT
// turned into local signals; they are forwarded to the DUT as raw bytes.
// - IXON off: Ctrl-S/Ctrl-Q flow control is forwarded to the DUT instead of
// being swallowed by the local terminal.
// - IEXTEN off: Ctrl-V and friends are forwarded literally.
// - ICRNL off: a typed CR is sent as CR, not translated to NL.
//
// dutctl is exited with the client-side escape sequence (Ctrl-A x), not with a
// terminal signal — see filterEscape in rpc.go.
//
// It returns a restore function, or nil if the fd is not a terminal (in which
// case input stays line-buffered, which is the correct fallback for pipes).
//
// The ioctl request numbers differ per OS (tcGetReq/tcSetReq are defined in the
// platform-specific files); the termios flags themselves are shared across
// unix platforms.
func setRawInput(fileDescriptor int) func() {
termios, err := unix.IoctlGetTermios(fileDescriptor, tcGetReq)
if err != nil {
return nil
}

old := *termios

termios.Iflag &^= unix.ICRNL | unix.IXON
termios.Lflag &^= unix.ECHO | unix.ICANON | unix.ISIG | unix.IEXTEN
termios.Cc[unix.VMIN] = 1
termios.Cc[unix.VTIME] = 0

err = unix.IoctlSetTermios(fileDescriptor, tcSetReq, termios)
if err != nil {
return nil
}

return func() {
_ = unix.IoctlSetTermios(fileDescriptor, tcSetReq, &old)
}
}
Loading
Loading