Skip to content

Commit 6c9386f

Browse files
authored
feat: live MITM cert reload (#18)
**feat: live MITM cert reload** Adds the ability to reload the MITM CA certificate in a running greyproxy instance without restarting the service. Before this PR, regenerating the CA cert required manually restarting greyproxy for the new cert to take effect. This PR makes cert rotation seamless — the daemon detects cert file changes automatically and reloads in place. First-time install also generates the CA certificate automatically. **CLI** * Added `greyproxy cert reload` subcommand — sends a reload request to the running daemon and reports success or failure * `greyproxy install` now auto-generates the CA certificate if one does not exist, so the first-time setup works out of the box **Daemon** * Extracted `injectCertPaths()` so cert path injection runs on both startup and config reload * Added `watchCertFiles()` goroutine — uses fsnotify (inotify/kqueue) and triggers a reload only after both `ca-cert.pem` and `ca-key.pem` have been written, preventing a key mismatch if the watcher fires between the two sequential writes * Added `certMtime` tracking so the daemon records the mtime of the cert at last successful load **API** * Added `POST /api/cert/reload` endpoint (`CertReloadHandler`) with mtime guard — skips reload and returns `cert unchanged, no reload needed` if the cert file has not changed since the last load **UI** * Removed 3 stale "restart greyproxy to apply" messages from the settings page **Tests** * `TestCertReloadHandler_unchanged_skipsReload` — mtime guard skips reload when cert is unchanged * `TestCertReloadHandler_changed_triggersReload` — reload fires when cert mtime has advanced * Tests for `injectCertPaths` * * * --- <img width="897" height="223" alt="yata" src="https://github.com/user-attachments/assets/c806b2b0-7147-4da9-a898-ac18e1859b80" /> --- <img width="1042" height="638" alt="tsl" src="https://github.com/user-attachments/assets/a8ebbe29-ef69-4b64-ac3f-5ad1ad07ff86" />
1 parent 262188a commit 6c9386f

File tree

11 files changed

+521
-44
lines changed

11 files changed

+521
-44
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,35 @@ cd greyproxy
4545
go build ./cmd/greyproxy
4646
```
4747

48+
**macOS only:** after building, codesign the binary to avoid Gatekeeper quarantine:
49+
50+
```bash
51+
codesign --sign - --force ./greyproxy
52+
```
53+
54+
Install the binary and register it as a service:
55+
56+
```bash
57+
./greyproxy install
58+
```
59+
60+
This copies the binary to `~/.local/bin/`, registers a launchd user agent (macOS) or systemd user service (Linux), and starts it automatically. The dashboard will be available at `http://localhost:43080`.
61+
62+
Generate and install the CA certificate for HTTPS inspection:
63+
64+
```bash
65+
greyproxy cert generate
66+
greyproxy cert install
67+
```
68+
69+
`greyproxy install` generates the certificate automatically on first install if one does not exist. If you regenerate the certificate later, greyproxy detects the change and reloads automatically — no restart needed. You can also trigger a reload manually:
70+
71+
```bash
72+
greyproxy cert reload
73+
```
74+
75+
Alternatively, use [`greywall setup`](https://github.com/GreyhavenHQ/greywall) to handle the full build and install automatically.
76+
4877
### Install
4978

5079
Install the binary to `~/.local/bin/` and register it as a systemd user service:

cmd/greyproxy/cert.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package main
22

33
import (
4+
"bytes"
45
"crypto/ecdsa"
56
"crypto/elliptic"
67
"crypto/rand"
78
"crypto/x509"
89
"crypto/x509/pkix"
10+
"encoding/json"
911
"encoding/pem"
1012
"fmt"
13+
"io"
1114
"math/big"
15+
"net/http"
1216
"os"
1317
"os/exec"
1418
"path/filepath"
@@ -24,6 +28,7 @@ Commands:
2428
generate Generate CA certificate and key pair
2529
install Trust the CA certificate on the OS
2630
uninstall Remove the CA certificate from the OS trust store
31+
reload Reload the CA certificate in the running greyproxy (no restart needed)
2732
2833
Options:
2934
-f Force overwrite existing files (generate, install)
@@ -40,6 +45,8 @@ Options:
4045
handleCertInstall(force)
4146
case "uninstall":
4247
handleCertUninstall()
48+
case "reload":
49+
handleCertReload()
4350
default:
4451
fmt.Fprintf(os.Stderr, "unknown cert command: %s\n", args[0])
4552
os.Exit(1)
@@ -62,14 +69,12 @@ func handleCertGenerate(force bool) {
6269
}
6370
}
6471

65-
// Generate ECDSA P-256 key
6672
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
6773
if err != nil {
6874
fmt.Fprintf(os.Stderr, "failed to generate private key: %v\n", err)
6975
os.Exit(1)
7076
}
7177

72-
// Create self-signed CA certificate
7378
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
7479
if err != nil {
7580
fmt.Fprintf(os.Stderr, "failed to generate serial number: %v\n", err)
@@ -96,13 +101,11 @@ func handleCertGenerate(force bool) {
96101
os.Exit(1)
97102
}
98103

99-
// Ensure data directory exists
100104
if err := os.MkdirAll(dataDir, 0700); err != nil {
101105
fmt.Fprintf(os.Stderr, "failed to create data directory: %v\n", err)
102106
os.Exit(1)
103107
}
104108

105-
// Write certificate
106109
certOut, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
107110
if err != nil {
108111
fmt.Fprintf(os.Stderr, "failed to write certificate: %v\n", err)
@@ -115,7 +118,6 @@ func handleCertGenerate(force bool) {
115118
}
116119
certOut.Close()
117120

118-
// Write private key
119121
keyBytes, err := x509.MarshalECPrivateKey(privateKey)
120122
if err != nil {
121123
fmt.Fprintf(os.Stderr, "failed to marshal private key: %v\n", err)
@@ -290,3 +292,30 @@ func handleCertUninstall() {
290292
fmt.Println("Please remove the Greyproxy CA certificate manually from your OS trust store.")
291293
}
292294
}
295+
296+
// handleCertReload sends a reload request to the running greyproxy instance.
297+
func handleCertReload() {
298+
apiURL := "http://localhost:43080/api/cert/reload"
299+
resp, err := http.Post(apiURL, "application/json", bytes.NewReader(nil)) //nolint:gosec,noctx // localhost only, no user input
300+
if err != nil {
301+
fmt.Fprintf(os.Stderr, "failed to reach greyproxy at %s: %v\n", apiURL, err)
302+
fmt.Fprintf(os.Stderr, "Is greyproxy running? Check with: greyproxy service status\n")
303+
os.Exit(1)
304+
}
305+
defer func() { _ = resp.Body.Close() }()
306+
307+
body, _ := io.ReadAll(resp.Body)
308+
if resp.StatusCode != http.StatusOK {
309+
fmt.Fprintf(os.Stderr, "reload failed (HTTP %d): %s\n", resp.StatusCode, string(body))
310+
os.Exit(1)
311+
}
312+
313+
var result struct {
314+
Message string `json:"message"`
315+
}
316+
if err := json.Unmarshal(body, &result); err == nil && result.Message != "" {
317+
fmt.Println(result.Message)
318+
} else {
319+
fmt.Println("MITM cert reloaded successfully.")
320+
}
321+
}

cmd/greyproxy/install.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ func handleInstall(args []string) {
109109
fmt.Printf("Ready to install greyproxy. This will:\n")
110110
fmt.Printf(" 1. Copy %s -> %s\n", binSrc, binDst)
111111
fmt.Printf(" 2. Register greyproxy as a %s\n", label)
112-
fmt.Printf(" 3. Start the service\n")
112+
fmt.Printf(" 3. Generate CA certificate (if not already present)\n")
113+
fmt.Printf(" 4. Start the service\n")
113114

114115
if !force {
115116
fmt.Printf("\nProceed? [Y/n] ")
@@ -226,6 +227,12 @@ func freshInstall(binSrc, binDst string) {
226227
}
227228
fmt.Printf("Registered %s\n", label)
228229

230+
// Generate CA certificate if not already present
231+
certFile := filepath.Join(greyproxyDataHome(), "ca-cert.pem")
232+
if _, err := os.Stat(certFile); os.IsNotExist(err) {
233+
handleCertGenerate(false)
234+
}
235+
229236
// Start service
230237
if err := service.Control(s, "start"); err != nil {
231238
fmt.Fprintf(os.Stderr, "error: starting service: %v\n", err)

cmd/greyproxy/program.go

Lines changed: 120 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import (
1515
"runtime"
1616
"strconv"
1717
"strings"
18-
"time"
18+
"sync"
1919
"syscall"
20+
"time"
2021

2122
"github.com/andybalholm/brotli"
23+
"github.com/fsnotify/fsnotify"
2224
"github.com/klauspost/compress/zstd"
2325
defaults "github.com/greyhavenhq/greyproxy"
2426
"github.com/greyhavenhq/greyproxy/internal/gostcore/logger"
@@ -47,6 +49,9 @@ type program struct {
4749
cancel context.CancelFunc
4850
assemblerCancel context.CancelFunc
4951
credStoreCancel context.CancelFunc
52+
53+
certMtimeMu sync.Mutex
54+
certMtime time.Time // mtime of ca-cert.pem at last successful reload
5055
}
5156

5257
func (p *program) initParser() {
@@ -75,27 +80,7 @@ func (p *program) Start(s service.Service) error {
7580
}
7681

7782
// Auto-inject MITM cert paths if CA files exist
78-
certFile := filepath.Join(greyproxyDataHome(), "ca-cert.pem")
79-
keyFile := filepath.Join(greyproxyDataHome(), "ca-key.pem")
80-
if _, err := os.Stat(certFile); err == nil {
81-
if _, err := os.Stat(keyFile); err == nil {
82-
for _, svc := range cfg.Services {
83-
if svc.Handler == nil {
84-
continue
85-
}
86-
if svc.Handler.Type != "http" && svc.Handler.Type != "socks5" {
87-
continue
88-
}
89-
if svc.Handler.Metadata == nil {
90-
svc.Handler.Metadata = make(map[string]any)
91-
}
92-
if _, ok := svc.Handler.Metadata["mitm.certFile"]; !ok {
93-
svc.Handler.Metadata["mitm.certFile"] = certFile
94-
svc.Handler.Metadata["mitm.keyFile"] = keyFile
95-
}
96-
}
97-
}
98-
}
83+
injectCertPaths(cfg, greyproxyDataHome())
9984

10085
config.Set(cfg)
10186

@@ -114,10 +99,104 @@ func (p *program) Start(s service.Service) error {
11499
ctx, cancel := context.WithCancel(context.Background())
115100
p.cancel = cancel
116101
go p.reload(ctx)
102+
go p.watchCertFiles(ctx, greyproxyDataHome())
117103

118104
return nil
119105
}
120106

107+
// injectCertPaths injects the CA cert/key paths into HTTP and SOCKS5 handler configs if the files exist.
108+
func injectCertPaths(cfg *config.Config, dataDir string) {
109+
certFile := filepath.Join(dataDir, "ca-cert.pem")
110+
keyFile := filepath.Join(dataDir, "ca-key.pem")
111+
if _, err := os.Stat(certFile); err != nil {
112+
return
113+
}
114+
if _, err := os.Stat(keyFile); err != nil {
115+
return
116+
}
117+
for _, svc := range cfg.Services {
118+
if svc.Handler == nil {
119+
continue
120+
}
121+
if svc.Handler.Type != "http" && svc.Handler.Type != "socks5" {
122+
continue
123+
}
124+
if svc.Handler.Metadata == nil {
125+
svc.Handler.Metadata = make(map[string]any)
126+
}
127+
if _, ok := svc.Handler.Metadata["mitm.certFile"]; !ok {
128+
svc.Handler.Metadata["mitm.certFile"] = certFile
129+
svc.Handler.Metadata["mitm.keyFile"] = keyFile
130+
}
131+
}
132+
}
133+
134+
// watchCertFiles watches ca-cert.pem and ca-key.pem using inotify (fsnotify) and
135+
// triggers a config reload when either file is written or created.
136+
func (p *program) watchCertFiles(ctx context.Context, dataDir string) {
137+
watcher, err := fsnotify.NewWatcher()
138+
if err != nil {
139+
logger.Default().Errorf("cert watcher: failed to create watcher: %v", err)
140+
return
141+
}
142+
defer watcher.Close()
143+
144+
if err := watcher.Add(dataDir); err != nil {
145+
logger.Default().Errorf("cert watcher: failed to watch %s: %v", dataDir, err)
146+
return
147+
}
148+
149+
certFile := filepath.Join(dataDir, "ca-cert.pem")
150+
keyFile := filepath.Join(dataDir, "ca-key.pem")
151+
152+
var debounce *time.Timer
153+
sawCert, sawKey := false, false
154+
for {
155+
select {
156+
case <-ctx.Done():
157+
if debounce != nil {
158+
debounce.Stop()
159+
}
160+
return
161+
case event, ok := <-watcher.Events:
162+
if !ok {
163+
return
164+
}
165+
if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
166+
continue
167+
}
168+
if event.Name == certFile {
169+
sawCert = true
170+
} else if event.Name == keyFile {
171+
sawKey = true
172+
} else {
173+
continue
174+
}
175+
176+
if !sawCert || !sawKey {
177+
continue
178+
}
179+
sawCert, sawKey = false, false
180+
if debounce != nil {
181+
debounce.Stop()
182+
}
183+
debounce = time.AfterFunc(100*time.Millisecond, func() {
184+
logger.Default().Info("cert files changed, reloading MITM cert...")
185+
if err := p.reloadConfig(); err != nil {
186+
logger.Default().Errorf("cert reload failed: %v", err)
187+
} else {
188+
logger.Default().Info("MITM cert reloaded")
189+
}
190+
})
191+
case err, ok := <-watcher.Errors:
192+
if !ok {
193+
return
194+
}
195+
logger.Default().Errorf("cert watcher error: %v", err)
196+
}
197+
}
198+
}
199+
121200
func (p *program) run(cfg *config.Config) error {
122201
for _, svc := range registry.ServiceRegistry().GetAll() {
123202
svc := svc
@@ -244,6 +323,7 @@ func (p *program) reloadConfig() error {
244323
if err != nil {
245324
return err
246325
}
326+
injectCertPaths(cfg, greyproxyDataHome())
247327
config.Set(cfg)
248328

249329
if err := loader.Load(cfg); err != nil {
@@ -254,6 +334,14 @@ func (p *program) reloadConfig() error {
254334
return err
255335
}
256336

337+
// Record mtime of ca-cert.pem so CertReloadHandler can detect no-op calls.
338+
certFile := filepath.Join(greyproxyDataHome(), "ca-cert.pem")
339+
if info, err := os.Stat(certFile); err == nil {
340+
p.certMtimeMu.Lock()
341+
p.certMtime = info.ModTime()
342+
p.certMtimeMu.Unlock()
343+
}
344+
257345
return nil
258346
}
259347

@@ -365,20 +453,26 @@ func (p *program) buildGreyproxyService() error {
365453
}
366454
}
367455

456+
shared.ReloadCertFn = p.reloadConfig
457+
shared.CertMtimeFn = func() time.Time {
458+
p.certMtimeMu.Lock()
459+
defer p.certMtimeMu.Unlock()
460+
return p.certMtime
461+
}
368462
shared.Version = version
369463

370464
// Collect listening ports for the health endpoint
371465
ports := make(map[string]int)
372466
if _, portStr, err := net.SplitHostPort(gaCfg.Addr); err == nil {
373-
if p, err := strconv.Atoi(portStr); err == nil {
374-
ports["api"] = p
467+
if portNum, err := strconv.Atoi(portStr); err == nil {
468+
ports["api"] = portNum
375469
}
376470
}
377471
for name, svc := range registry.ServiceRegistry().GetAll() {
378472
if addr := svc.Addr(); addr != nil {
379473
if _, portStr, err := net.SplitHostPort(addr.String()); err == nil {
380-
if p, err := strconv.Atoi(portStr); err == nil {
381-
ports[name] = p
474+
if portNum, err := strconv.Atoi(portStr); err == nil {
475+
ports[name] = portNum
382476
}
383477
}
384478
}

0 commit comments

Comments
 (0)