Skip to content

Commit ed5eea3

Browse files
committed
feat: add TLS encryption for client-agent communication
Implement TLS support using Ed25519 self-signed certificates to encrypt communication between dutctl client and dutagent server. TLS is enabled by default with an --insecure flag available for HTTP support. This provides encryption only, not client authentication. Any client can connect to the agent. Signed-off-by: Fabian Wienand <fabian.wienand@9elements.com>
1 parent 0f94dc6 commit ed5eea3

File tree

4 files changed

+306
-19
lines changed

4 files changed

+306
-19
lines changed

cmds/dutagent/dutagent.go

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package main
99

1010
import (
1111
"context"
12+
"crypto/tls"
1213
"errors"
1314
"flag"
1415
"fmt"
@@ -18,10 +19,12 @@ import (
1819
"os"
1920
"os/signal"
2021
"syscall"
22+
"time"
2123

2224
"connectrpc.com/connect"
2325
"github.com/BlindspotSoftware/dutctl/internal/buildinfo"
2426
"github.com/BlindspotSoftware/dutctl/internal/dutagent"
27+
"github.com/BlindspotSoftware/dutctl/internal/tlsutil"
2528
"github.com/BlindspotSoftware/dutctl/pkg/dut"
2629
"github.com/BlindspotSoftware/dutctl/pkg/rpc"
2730
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
@@ -54,6 +57,9 @@ func newAgent(stdout io.Writer, exitFunc func(int), args []string) *agent {
5457
fs.BoolVar(&agt.dryRun, "dry-run", false, dryRunInfo)
5558
fs.StringVar(&agt.server, "server", "", serverInfo)
5659
fs.BoolVar(&agt.versionFlag, "v", false, versionFlagInfo)
60+
fs.BoolVar(&agt.insecure, "insecure", false, "Disable TLS (use plain HTTP)")
61+
fs.StringVar(&agt.tlsCertPath, "tls-cert", "/etc/dutagent/tls/cert.pem", "Path to TLS certificate file (auto-generated if missing)")
62+
fs.StringVar(&agt.tlsKeyPath, "tls-key", "/etc/dutagent/tls/key.pem", "Path to TLS key file (auto-generated if missing)")
5763
//nolint:errcheck // flag.Parse always returns no error because of flag.ExitOnError
5864
fs.Parse(args[1:])
5965

@@ -72,6 +78,9 @@ type agent struct {
7278
checkConfig bool
7379
dryRun bool
7480
server string
81+
insecure bool
82+
tlsCertPath string
83+
tlsKeyPath string
7584

7685
// state
7786
config config
@@ -152,6 +161,10 @@ func printInitErr(err error) {
152161
log.Print(err)
153162
}
154163

164+
const (
165+
readHeaderTimeout = 10 * time.Second
166+
)
167+
155168
// startRPCService starts the RPC service, that ideally listens for incoming
156169
// connections forever. It always returns an non-nil error.
157170
func (agt *agent) startRPCService() error {
@@ -163,18 +176,44 @@ func (agt *agent) startRPCService() error {
163176
path, handler := dutctlv1connect.NewDeviceServiceHandler(service)
164177
mux.Handle(path, handler)
165178

166-
//nolint:gosec
167-
return http.ListenAndServe(
168-
agt.address,
169-
// Use h2c so we can serve HTTP/2 without TLS.
170-
h2c.NewHandler(mux, &http2.Server{}),
171-
)
179+
if agt.insecure {
180+
// Use h2c so we can serve HTTP/2 without TLS
181+
log.Printf("Starting in INSECURE mode (plain HTTP) on %s", agt.address)
182+
//nolint:gosec
183+
return http.ListenAndServe(
184+
agt.address,
185+
h2c.NewHandler(mux, &http2.Server{}),
186+
)
187+
}
188+
189+
// Use TLS mode (default) - load or auto-generate certificate
190+
cert, err := tlsutil.LoadOrGenerateCert(agt.tlsCertPath, agt.tlsKeyPath)
191+
if err != nil {
192+
return fmt.Errorf("failed to load/generate TLS certificate: %w", err)
193+
}
194+
195+
tlsConfig := &tls.Config{
196+
Certificates: []tls.Certificate{cert},
197+
MinVersion: tls.VersionTLS13,
198+
}
199+
200+
server := &http.Server{
201+
Addr: agt.address,
202+
Handler: mux,
203+
TLSConfig: tlsConfig,
204+
ReadHeaderTimeout: readHeaderTimeout,
205+
}
206+
207+
log.Printf("Starting TLS-enabled RPC service on %s", agt.address)
208+
209+
// ListenAndServeTLS with empty cert/key paths since we've already loaded them in tlsConfig
210+
return server.ListenAndServeTLS("", "")
172211
}
173212

174213
func (agt *agent) registerWithServer() error {
175214
log.Printf("Registering with server %q", agt.server)
176215

177-
client := spawnClient(agt.server)
216+
client := spawnClient(agt.server, agt.insecure)
178217
req := connect.NewRequest(&pb.RegisterRequest{
179218
Devices: agt.config.Devices.Names(),
180219
Address: agt.address,
@@ -191,15 +230,20 @@ func (agt *agent) registerWithServer() error {
191230
}
192231

193232
// spawnClient creates a new client to the DUT server specified by the server address.
194-
// TODO: refactor into pkg and reuse in dutctl and dutserver.
195233
//
196234
//nolint:ireturn
197-
func spawnClient(agendURL string) dutctlv1connect.RelayServiceClient {
198-
log.Printf("Spawning new client for agent %q", agendURL)
235+
func spawnClient(serverURL string, insecure bool) dutctlv1connect.RelayServiceClient {
236+
client, scheme := rpc.NewClient(insecure)
237+
238+
if insecure {
239+
log.Printf("Spawning insecure client for server %q", serverURL)
240+
} else {
241+
log.Printf("Spawning TLS client for server %q", serverURL)
242+
}
199243

200244
return dutctlv1connect.NewRelayServiceClient(
201-
rpc.NewInsecureClient(),
202-
fmt.Sprintf("http://%s", agendURL),
245+
client,
246+
fmt.Sprintf("%s://%s", scheme, serverURL),
203247
connect.WithGRPC(),
204248
)
205249
}

cmds/dutctl/dutctl.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func newApp(stdin io.Reader, stdout, stderr io.Writer, exitFunc func(int), args
7777
fs.StringVar(&app.outputFormat, "f", "", outputFormatInfo)
7878
fs.BoolVar(&app.verbose, "v", false, verboseInfo)
7979
fs.BoolVar(&app.noColor, "no-color", false, noColorInfo)
80+
fs.BoolVar(&app.insecure, "insecure", false, "Disable TLS (use plain HTTP)")
8081

8182
//nolint:errcheck // flag.Parse always returns no error because of flag.ExitOnError
8283
fs.Parse(args[1:])
@@ -105,6 +106,7 @@ type application struct {
105106
outputFormat string
106107
verbose bool
107108
noColor bool
109+
insecure bool
108110
args []string
109111
printFlagDefaults func()
110112

@@ -113,13 +115,13 @@ type application struct {
113115
}
114116

115117
func (app *application) setupRPCClient() {
116-
client := dutctlv1connect.NewDeviceServiceClient(
117-
rpc.NewInsecureClient(),
118-
fmt.Sprintf("http://%s", app.serverAddr),
118+
client, scheme := rpc.NewClient(app.insecure)
119+
120+
app.rpcClient = dutctlv1connect.NewDeviceServiceClient(
121+
client,
122+
fmt.Sprintf("%s://%s", scheme, app.serverAddr),
119123
connect.WithGRPC(),
120124
)
121-
122-
app.rpcClient = client
123125
}
124126

125127
var errInvalidCmdline = fmt.Errorf("invalid command line")

internal/tlsutil/tlsutil.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package tlsutil
6+
7+
import (
8+
"crypto/ed25519"
9+
"crypto/rand"
10+
"crypto/tls"
11+
"crypto/x509"
12+
"crypto/x509/pkix"
13+
"encoding/pem"
14+
"fmt"
15+
"log"
16+
"math/big"
17+
"net"
18+
"os"
19+
"path/filepath"
20+
"time"
21+
)
22+
23+
const (
24+
// File and directory permissions.
25+
certFileMode = 0644 // Public read, owner write.
26+
keyFileMode = 0600 // Owner read/write only
27+
dirMode = 0755 // Standard directory permissions.
28+
29+
// Certificate serial number bit size.
30+
serialNumberBits = 128
31+
)
32+
33+
// GenerateSelfSignedCert creates a new self-signed TLS certificate and private key.
34+
// The certificate is valid for 10 years and includes localhost and system hostname in SANs.
35+
// Uses Ed25519 for better performance and security compared to RSA.
36+
func GenerateSelfSignedCert(certPath, keyPath string) error {
37+
// Generate Ed25519 private key (much faster than RSA)
38+
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
39+
if err != nil {
40+
return fmt.Errorf("failed to generate keys: %w", err)
41+
}
42+
43+
// Create self-signed certificate
44+
derBytes, err := createSelfSignedCertificate(publicKey, privateKey)
45+
if err != nil {
46+
return err
47+
}
48+
49+
err = writeCertificate(certPath, derBytes)
50+
if err != nil {
51+
return err
52+
}
53+
54+
err = writePrivateKey(keyPath, privateKey)
55+
if err != nil {
56+
return err
57+
}
58+
59+
log.Printf("Generated self-signed TLS certificate: %s", certPath)
60+
log.Printf("Generated private key: %s", keyPath)
61+
62+
return nil
63+
}
64+
65+
func createSelfSignedCertificate(publicKey ed25519.PublicKey, privateKey ed25519.PrivateKey) ([]byte, error) {
66+
// Generate a random serial number
67+
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), serialNumberBits)
68+
69+
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
70+
if err != nil {
71+
return nil, fmt.Errorf("failed to generate serial number: %w", err)
72+
}
73+
74+
// Get system hostname for SANs
75+
hostname, err := os.Hostname()
76+
if err != nil {
77+
hostname = "localhost" // Fallback if hostname detection fails
78+
}
79+
80+
template := &x509.Certificate{
81+
SerialNumber: serialNumber,
82+
Subject: pkix.Name{
83+
Organization: []string{"Blindspot Software"},
84+
CommonName: "dutagent",
85+
},
86+
NotBefore: time.Now(),
87+
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years
88+
KeyUsage: x509.KeyUsageDigitalSignature,
89+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
90+
BasicConstraintsValid: true,
91+
DNSNames: []string{"localhost", hostname},
92+
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
93+
}
94+
95+
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
96+
if err != nil {
97+
return nil, fmt.Errorf("failed to create certificate: %w", err)
98+
}
99+
100+
return derBytes, nil
101+
}
102+
103+
func writeCertificate(certPath string, derBytes []byte) error {
104+
certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, certFileMode)
105+
if err != nil {
106+
return fmt.Errorf("failed to create certificate file: %w", err)
107+
}
108+
defer certOut.Close()
109+
110+
err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
111+
if err != nil {
112+
return fmt.Errorf("failed to write certificate: %w", err)
113+
}
114+
115+
return nil
116+
}
117+
118+
func writePrivateKey(keyPath string, privateKey ed25519.PrivateKey) error {
119+
// Marshal Ed25519 private key in PKCS8 format
120+
privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
121+
if err != nil {
122+
return fmt.Errorf("failed to marshal private key: %w", err)
123+
}
124+
125+
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, keyFileMode)
126+
if err != nil {
127+
return fmt.Errorf("failed to create key file: %w", err)
128+
}
129+
defer keyOut.Close()
130+
131+
err = pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
132+
if err != nil {
133+
return fmt.Errorf("failed to write private key: %w", err)
134+
}
135+
136+
return nil
137+
}
138+
139+
// LoadOrGenerateCert attempts to load an existing TLS certificate/key pair.
140+
// If the files don't exist, it generates a new self-signed certificate.
141+
// If the files exist but cannot be loaded, it returns an error without overwriting them.
142+
func LoadOrGenerateCert(certPath, keyPath string) (tls.Certificate, error) {
143+
// Check if certificate and key files exist
144+
certExists := fileExists(certPath)
145+
keyExists := fileExists(keyPath)
146+
147+
// If either file exists, we must load them (don't auto-generate)
148+
if certExists || keyExists {
149+
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
150+
if err != nil {
151+
return tls.Certificate{}, fmt.Errorf("certificate/key files exist but failed to load (cert exists: %v, key exists: %v): %w",
152+
certExists, keyExists, err)
153+
}
154+
155+
log.Printf("Loaded existing TLS certificate from: %s", certPath)
156+
157+
return cert, nil
158+
}
159+
160+
// Neither file exists, generate new certificate
161+
log.Printf("TLS certificate not found, generating new self-signed certificate...")
162+
163+
// Derive directory from cert path
164+
certDir := filepath.Dir(certPath)
165+
keyDir := filepath.Dir(keyPath)
166+
167+
// Ensure directories exist
168+
err := os.MkdirAll(certDir, dirMode)
169+
if err != nil {
170+
return tls.Certificate{}, fmt.Errorf("failed to create certificate directory: %w", err)
171+
}
172+
173+
if certDir != keyDir {
174+
err := os.MkdirAll(keyDir, dirMode)
175+
if err != nil {
176+
return tls.Certificate{}, fmt.Errorf("failed to create key directory: %w", err)
177+
}
178+
}
179+
180+
// Generate certificate
181+
err = GenerateSelfSignedCert(certPath, keyPath)
182+
if err != nil {
183+
return tls.Certificate{}, err
184+
}
185+
186+
// Load the newly generated certificate
187+
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
188+
if err != nil {
189+
return tls.Certificate{}, fmt.Errorf("failed to load generated certificate: %w", err)
190+
}
191+
192+
return cert, nil
193+
}
194+
195+
// fileExists checks if a file exists and is not a directory.
196+
func fileExists(path string) bool {
197+
info, err := os.Stat(path)
198+
if err != nil {
199+
return false
200+
}
201+
202+
return !info.IsDir()
203+
}

0 commit comments

Comments
 (0)