Skip to content

Commit 2b64792

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 78a623c commit 2b64792

3 files changed

Lines changed: 223 additions & 11 deletions

File tree

cmds/dutagent/dutagent.go

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"connectrpc.com/connect"
2525
"github.com/BlindspotSoftware/dutctl/internal/buildinfo"
2626
"github.com/BlindspotSoftware/dutctl/internal/dutagent"
27+
"github.com/BlindspotSoftware/dutctl/internal/tlsutil"
2728
"github.com/BlindspotSoftware/dutctl/pkg/dut"
2829
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
2930
"golang.org/x/net/http2"
@@ -55,6 +56,9 @@ func newAgent(stdout io.Writer, exitFunc func(int), args []string) *agent {
5556
fs.BoolVar(&agt.dryRun, "dry-run", false, dryRunInfo)
5657
fs.StringVar(&agt.server, "server", "", serverInfo)
5758
fs.BoolVar(&agt.versionFlag, "v", false, versionFlagInfo)
59+
fs.BoolVar(&agt.insecure, "insecure", false, "Disable TLS (use plain HTTP)")
60+
fs.StringVar(&agt.tlsCertPath, "tls-cert", "/etc/dutagent/tls/cert.pem", "Path to TLS certificate file (auto-generated if missing)")
61+
fs.StringVar(&agt.tlsKeyPath, "tls-key", "/etc/dutagent/tls/key.pem", "Path to TLS key file (auto-generated if missing)")
5862
//nolint:errcheck // flag.Parse always returns no error because of flag.ExitOnError
5963
fs.Parse(args[1:])
6064

@@ -73,6 +77,9 @@ type agent struct {
7377
checkConfig bool
7478
dryRun bool
7579
server string
80+
insecure bool
81+
tlsCertPath string
82+
tlsKeyPath string
7683

7784
// state
7885
config config
@@ -164,12 +171,37 @@ func (agt *agent) startRPCService() error {
164171
path, handler := dutctlv1connect.NewDeviceServiceHandler(service)
165172
mux.Handle(path, handler)
166173

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

175207
func (agt *agent) registerWithServer() error {

cmds/dutctl/dutctl.go

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

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

@@ -114,14 +116,33 @@ type application struct {
114116
}
115117

116118
func (app *application) setupRPCClient() {
117-
client := dutctlv1connect.NewDeviceServiceClient(
118-
// Instead of http.DefaultClient, use the HTTP/2 protocol without TLS
119-
newInsecureClient(),
120-
fmt.Sprintf("http://%s", app.serverAddr),
119+
var client *http.Client
120+
var scheme string
121+
122+
if app.insecure {
123+
client = newInsecureClient()
124+
scheme = "http"
125+
} else {
126+
client = newTLSClient()
127+
scheme = "https"
128+
}
129+
130+
app.rpcClient = dutctlv1connect.NewDeviceServiceClient(
131+
client,
132+
fmt.Sprintf("%s://%s", scheme, app.serverAddr),
121133
connect.WithGRPC(),
122134
)
135+
}
123136

124-
app.rpcClient = client
137+
func newTLSClient() *http.Client {
138+
return &http.Client{
139+
Transport: &http2.Transport{
140+
TLSClientConfig: &tls.Config{
141+
InsecureSkipVerify: true, // Skip certificate verification
142+
MinVersion: tls.VersionTLS12,
143+
},
144+
},
145+
}
125146
}
126147

127148
func newInsecureClient() *http.Client {

internal/tlsutil/tlsutil.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
// GenerateSelfSignedCert creates a new self-signed TLS certificate and private key.
24+
// The certificate is valid for 10 years and includes localhost and system hostname in SANs.
25+
// Uses Ed25519 for better performance and security compared to RSA.
26+
func GenerateSelfSignedCert(certPath, keyPath string) error {
27+
// Generate Ed25519 private key (much faster than RSA)
28+
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
29+
if err != nil {
30+
return fmt.Errorf("failed to generate Ed25519 key: %w", err)
31+
}
32+
33+
// Generate a random serial number
34+
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
35+
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
36+
if err != nil {
37+
return fmt.Errorf("failed to generate serial number: %w", err)
38+
}
39+
40+
// Get system hostname for SANs
41+
hostname, err := os.Hostname()
42+
if err != nil {
43+
hostname = "localhost" // Fallback if hostname detection fails
44+
}
45+
46+
// Create certificate template
47+
template := x509.Certificate{
48+
SerialNumber: serialNumber,
49+
Subject: pkix.Name{
50+
Organization: []string{"Blindspot Software"},
51+
CommonName: "dutagent",
52+
},
53+
NotBefore: time.Now(),
54+
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years
55+
KeyUsage: x509.KeyUsageDigitalSignature,
56+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
57+
BasicConstraintsValid: true,
58+
DNSNames: []string{"localhost", hostname},
59+
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
60+
}
61+
62+
// Create self-signed certificate
63+
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey, privateKey)
64+
if err != nil {
65+
return fmt.Errorf("failed to create certificate: %w", err)
66+
}
67+
68+
// Write certificate to file with secure permissions
69+
certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
70+
if err != nil {
71+
return fmt.Errorf("failed to create cert file: %w", err)
72+
}
73+
defer certOut.Close()
74+
75+
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
76+
return fmt.Errorf("failed to write certificate: %w", err)
77+
}
78+
79+
// Marshal Ed25519 private key in PKCS8 format
80+
privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
81+
if err != nil {
82+
return fmt.Errorf("failed to marshal private key: %w", err)
83+
}
84+
85+
// Write private key to file with restrictive permissions
86+
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
87+
if err != nil {
88+
return fmt.Errorf("failed to create key file: %w", err)
89+
}
90+
defer keyOut.Close()
91+
92+
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
93+
return fmt.Errorf("failed to write private key: %w", err)
94+
}
95+
96+
log.Printf("Generated Ed25519 self-signed TLS certificate: %s", certPath)
97+
log.Printf("Generated private key: %s", keyPath)
98+
99+
return nil
100+
}
101+
102+
// LoadOrGenerateCert attempts to load an existing TLS certificate/key pair.
103+
// If the files don't exist, it generates a new self-signed certificate.
104+
// If the files exist but cannot be loaded, it returns an error without overwriting them.
105+
func LoadOrGenerateCert(certPath, keyPath string) (tls.Certificate, error) {
106+
// Check if certificate and key files exist
107+
certExists := fileExists(certPath)
108+
keyExists := fileExists(keyPath)
109+
110+
// If either file exists, we must load them (don't auto-generate)
111+
if certExists || keyExists {
112+
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
113+
if err != nil {
114+
return tls.Certificate{}, fmt.Errorf("certificate/key files exist but failed to load (cert exists: %v, key exists: %v): %w",
115+
certExists, keyExists, err)
116+
}
117+
log.Printf("Loaded existing TLS certificate from: %s", certPath)
118+
return cert, nil
119+
}
120+
121+
// Neither file exists, generate new certificate
122+
log.Printf("TLS certificate not found, generating new self-signed certificate...")
123+
124+
// Derive directory from cert path
125+
certDir := filepath.Dir(certPath)
126+
keyDir := filepath.Dir(keyPath)
127+
128+
// Ensure directories exist
129+
if err := os.MkdirAll(certDir, 0755); err != nil {
130+
return tls.Certificate{}, fmt.Errorf("failed to create cert directory: %w", err)
131+
}
132+
if certDir != keyDir {
133+
if err := os.MkdirAll(keyDir, 0755); err != nil {
134+
return tls.Certificate{}, fmt.Errorf("failed to create key directory: %w", err)
135+
}
136+
}
137+
138+
// Generate certificate
139+
if err := GenerateSelfSignedCert(certPath, keyPath); err != nil {
140+
return tls.Certificate{}, err
141+
}
142+
143+
// Load the newly generated certificate
144+
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
145+
if err != nil {
146+
return tls.Certificate{}, fmt.Errorf("failed to load generated certificate: %w", err)
147+
}
148+
149+
return cert, nil
150+
}
151+
152+
// fileExists checks if a file exists and is not a directory.
153+
func fileExists(path string) bool {
154+
info, err := os.Stat(path)
155+
if err != nil {
156+
return false
157+
}
158+
return !info.IsDir()
159+
}

0 commit comments

Comments
 (0)