Skip to content
Closed
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
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ homebrew_casks:
owner: GreyhavenHQ
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
skip_upload: auto
homepage: "https://github.com/GreyhavenHQ/greyproxy"
description: "SOCKS5 proxy with DNS resolution and web dashboard for network sandboxing"
license: "Apache-2.0"
Expand Down
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,12 @@ all-arch: $(PLATFORM_LIST) $(WINDOWS_ARCH_LIST)
releases: $(gz_releases) $(zip_releases)
clean:
rm $(BINDIR)/*

release:
@./scripts/release.sh patch

release-minor:
@./scripts/release.sh minor

release-beta:
@./scripts/release.sh beta
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ cd greyproxy
go build ./cmd/greyproxy
```

**macOS only:** after building, codesign the binary to avoid Gatekeeper quarantine:

```bash
codesign --sign - --force ./greyproxy
```

Install the binary and register it as a service:

```bash
./greyproxy install
```

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`.

Generate and install the CA certificate for HTTPS inspection:

```bash
greyproxy cert generate
greyproxy cert install
```

Alternatively, use [`greywall setup`](https://github.com/GreyhavenHQ/greywall) to handle the full build and install automatically.

### Install

Install the binary to `~/.local/bin/` and register it as a systemd user service:
Expand Down
39 changes: 34 additions & 5 deletions cmd/greyproxy/cert.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package main

import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"os"
"os/exec"
"path/filepath"
Expand All @@ -24,6 +28,7 @@ Commands:
generate Generate CA certificate and key pair
install Trust the CA certificate on the OS
uninstall Remove the CA certificate from the OS trust store
reload Reload the CA certificate in the running greyproxy (no restart needed)

Options:
-f Force overwrite existing files (generate, install)
Expand All @@ -40,6 +45,8 @@ Options:
handleCertInstall(force)
case "uninstall":
handleCertUninstall()
case "reload":
handleCertReload()
default:
fmt.Fprintf(os.Stderr, "unknown cert command: %s\n", args[0])
os.Exit(1)
Expand All @@ -62,14 +69,12 @@ func handleCertGenerate(force bool) {
}
}

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

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

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

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

// Write private key
keyBytes, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to marshal private key: %v\n", err)
Expand Down Expand Up @@ -290,3 +292,30 @@ func handleCertUninstall() {
fmt.Println("Please remove the Greyproxy CA certificate manually from your OS trust store.")
}
}

// handleCertReload sends a reload request to the running greyproxy instance.
func handleCertReload() {
apiURL := "http://localhost:43080/api/cert/reload"
resp, err := http.Post(apiURL, "application/json", bytes.NewReader(nil)) //nolint:gosec,noctx // localhost only, no user input
if err != nil {
fmt.Fprintf(os.Stderr, "failed to reach greyproxy at %s: %v\n", apiURL, err)
fmt.Fprintf(os.Stderr, "Is greyproxy running? Check with: greyproxy service status\n")
os.Exit(1)
}
defer func() { _ = resp.Body.Close() }()

body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "reload failed (HTTP %d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}

var result struct {
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err == nil && result.Message != "" {
fmt.Println(result.Message)
} else {
fmt.Println("MITM cert reloaded successfully.")
}
}
108 changes: 83 additions & 25 deletions cmd/greyproxy/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strconv"
"strings"
"syscall"
"time"

"github.com/andybalholm/brotli"
"github.com/klauspost/compress/zstd"
Expand Down Expand Up @@ -73,27 +74,7 @@ func (p *program) Start(s service.Service) error {
}

// Auto-inject MITM cert paths if CA files exist
certFile := filepath.Join(greyproxyDataHome(), "ca-cert.pem")
keyFile := filepath.Join(greyproxyDataHome(), "ca-key.pem")
if _, err := os.Stat(certFile); err == nil {
if _, err := os.Stat(keyFile); err == nil {
for _, svc := range cfg.Services {
if svc.Handler == nil {
continue
}
if svc.Handler.Type != "http" && svc.Handler.Type != "socks5" {
continue
}
if svc.Handler.Metadata == nil {
svc.Handler.Metadata = make(map[string]any)
}
if _, ok := svc.Handler.Metadata["mitm.certFile"]; !ok {
svc.Handler.Metadata["mitm.certFile"] = certFile
svc.Handler.Metadata["mitm.keyFile"] = keyFile
}
}
}
}
injectCertPaths(cfg, greyproxyDataHome())

config.Set(cfg)

Expand All @@ -112,10 +93,85 @@ func (p *program) Start(s service.Service) error {
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.reload(ctx)
go p.watchCertFiles(ctx, greyproxyDataHome())

return nil
}

// injectCertPaths injects the CA cert/key paths into HTTP and SOCKS5 handler configs if the files exist.
func injectCertPaths(cfg *config.Config, dataDir string) {
certFile := filepath.Join(dataDir, "ca-cert.pem")
keyFile := filepath.Join(dataDir, "ca-key.pem")
if _, err := os.Stat(certFile); err != nil {
return
}
if _, err := os.Stat(keyFile); err != nil {
return
}
for _, svc := range cfg.Services {
if svc.Handler == nil {
continue
}
if svc.Handler.Type != "http" && svc.Handler.Type != "socks5" {
continue
}
if svc.Handler.Metadata == nil {
svc.Handler.Metadata = make(map[string]any)
}
if _, ok := svc.Handler.Metadata["mitm.certFile"]; !ok {
svc.Handler.Metadata["mitm.certFile"] = certFile
svc.Handler.Metadata["mitm.keyFile"] = keyFile
}
}
}

// watchCertFiles polls ca-cert.pem and ca-key.pem for changes and
// triggers a config reload (which re-reads the cert files) when they change.
func (p *program) watchCertFiles(ctx context.Context, dataDir string) {
certFile := filepath.Join(dataDir, "ca-cert.pem")
keyFile := filepath.Join(dataDir, "ca-key.pem")

// Record initial modification times
mtimes := map[string]time.Time{}
for _, f := range []string{certFile, keyFile} {
if info, err := os.Stat(f); err == nil {
mtimes[f] = info.ModTime()
}
}

ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
changed := false
for _, f := range []string{certFile, keyFile} {
info, err := os.Stat(f)
if err != nil {
continue
}
if !info.ModTime().Equal(mtimes[f]) {
mtimes[f] = info.ModTime()
changed = true
}
}
if changed {
// Brief pause so both cert and key are fully written before reload
time.Sleep(500 * time.Millisecond)
logger.Default().Info("cert files changed, reloading MITM cert...")
if err := p.reloadConfig(); err != nil {
logger.Default().Errorf("cert reload failed: %v", err)
} else {
logger.Default().Info("MITM cert reloaded")
}
}
}
}
}

func (p *program) run(cfg *config.Config) error {
for _, svc := range registry.ServiceRegistry().GetAll() {
svc := svc
Expand Down Expand Up @@ -239,6 +295,7 @@ func (p *program) reloadConfig() error {
if err != nil {
return err
}
injectCertPaths(cfg, greyproxyDataHome())
config.Set(cfg)

if err := loader.Load(cfg); err != nil {
Expand Down Expand Up @@ -322,20 +379,21 @@ func (p *program) buildGreyproxyService() error {
gostx.SetGlobalMitmEnabled(enabled)
})

shared.ReloadCertFn = p.reloadConfig
shared.Version = version

// Collect listening ports for the health endpoint
ports := make(map[string]int)
if _, portStr, err := net.SplitHostPort(gaCfg.Addr); err == nil {
if p, err := strconv.Atoi(portStr); err == nil {
ports["api"] = p
if portNum, err := strconv.Atoi(portStr); err == nil {
ports["api"] = portNum
}
}
for name, svc := range registry.ServiceRegistry().GetAll() {
if addr := svc.Addr(); addr != nil {
if _, portStr, err := net.SplitHostPort(addr.String()); err == nil {
if p, err := strconv.Atoi(portStr); err == nil {
ports[name] = p
if portNum, err := strconv.Atoi(portStr); err == nil {
ports[name] = portNum
}
}
}
Expand Down
21 changes: 18 additions & 3 deletions internal/greyproxy/api/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ func CertGenerateHandler(s *Shared) gin.HandlerFunc {
return
}

// Generate ECDSA P-256 key
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to generate key: %v", err)})
Expand Down Expand Up @@ -162,7 +161,6 @@ func CertGenerateHandler(s *Shared) gin.HandlerFunc {
return
}

// Write certificate
certOut, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write cert: %v", err)})
Expand All @@ -175,7 +173,6 @@ func CertGenerateHandler(s *Shared) gin.HandlerFunc {
}
certOut.Close()

// Write private key
keyBytes, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to marshal key: %v", err)})
Expand Down Expand Up @@ -212,3 +209,21 @@ func CertDownloadHandler(s *Shared) gin.HandlerFunc {
c.File(certPath)
}
}

// CertReloadHandler triggers a live reload of the MITM CA certificate.
func CertReloadHandler(s *Shared) gin.HandlerFunc {
return func(c *gin.Context) {
if s.ReloadCertFn == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cert reload not available"})
return
}
if err := s.ReloadCertFn(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("reload failed: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "MITM cert reloaded",
"certStatus": buildCertStatus(s.DataHome),
})
}
}
4 changes: 3 additions & 1 deletion internal/greyproxy/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ type Shared struct {
Assembler *greyproxy.ConversationAssembler
Version string
Ports map[string]int
DataHome string // Path to greyproxy data directory (contains CA cert/key)
DataHome string // Path to greyproxy data directory (contains CA cert/key)
ReloadCertFn func() error // Triggers a live MITM cert reload (set by the running service)
}

// NewRouter creates the Gin router with all routes.
Expand Down Expand Up @@ -89,6 +90,7 @@ func NewRouter(s *Shared, pathPrefix string) (*gin.Engine, *gin.RouterGroup) {
api.GET("/cert/status", CertStatusHandler(s))
api.POST("/cert/generate", CertGenerateHandler(s))
api.GET("/cert/download", CertDownloadHandler(s))
api.POST("/cert/reload", CertReloadHandler(s))

// Maintenance
api.POST("/maintenance/rebuild-conversations", RebuildConversationsHandler(s))
Expand Down
Loading