mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat(proxy): add persistent proxy server with start/stop/env/status commands
Adds pmg proxy command group for running a long-lived MITM proxy that intercepts all package manager traffic without requiring PMG shims or wrappers. Targets CI/CD pipelines where env vars can be set globally. - pmg proxy start: starts proxy with npm+pypi interceptors, writes state file (pid/addr/ca-cert-path), auto-blocks suspicious packages - pmg proxy stop: sends SIGTERM to the running proxy - pmg proxy env: emits HTTP_PROXY/HTTPS_PROXY/SSL_CERT_FILE etc. as shell exports, or writes directly to $GITHUB_ENV with --gha - pmg proxy status: shows running/stopped status GHA usage: pmg proxy start & pmg proxy env --gha # populates env for all subsequent steps npm install # intercepted automatically, no wrapper needed Also adds .github/workflows/persistent-proxy-e2e.yml to validate the persistent proxy mode end-to-end in CI.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/proxystate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newEnvCommand() *cobra.Command {
|
||||
var gha bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "env",
|
||||
Short: "Print proxy environment variables (use with eval or --gha for GitHub Actions)",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return runEnv(gha)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&gha, "gha", false, "Write env vars to $GITHUB_ENV instead of stdout")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runEnv(gha bool) error {
|
||||
cfg := config.Get()
|
||||
statePath := proxystate.StatePath(cfg.ConfigDir())
|
||||
|
||||
state, err := proxystate.Read(statePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxy not running — start with 'pmg proxy start' first: %w", err)
|
||||
}
|
||||
|
||||
proxyURL := fmt.Sprintf("http://%s", state.Addr)
|
||||
noProxy := "localhost,127.0.0.1,::1"
|
||||
|
||||
vars := []string{
|
||||
fmt.Sprintf("HTTP_PROXY=%s", proxyURL),
|
||||
fmt.Sprintf("HTTPS_PROXY=%s", proxyURL),
|
||||
fmt.Sprintf("http_proxy=%s", proxyURL),
|
||||
fmt.Sprintf("https_proxy=%s", proxyURL),
|
||||
fmt.Sprintf("NO_PROXY=%s", noProxy),
|
||||
fmt.Sprintf("no_proxy=%s", noProxy),
|
||||
"NODE_USE_ENV_PROXY=1",
|
||||
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", state.CACertPath),
|
||||
fmt.Sprintf("SSL_CERT_FILE=%s", state.CACertPath),
|
||||
fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", state.CACertPath),
|
||||
fmt.Sprintf("PIP_CERT=%s", state.CACertPath),
|
||||
fmt.Sprintf("PIP_PROXY=%s", proxyURL),
|
||||
"PIP_RETRIES=0",
|
||||
fmt.Sprintf("YARN_HTTP_PROXY=%s", proxyURL),
|
||||
fmt.Sprintf("YARN_HTTPS_PROXY=%s", proxyURL),
|
||||
fmt.Sprintf("YARN_HTTPS_CA_FILE_PATH=%s", state.CACertPath),
|
||||
}
|
||||
|
||||
if gha {
|
||||
return writeGitHubEnv(vars)
|
||||
}
|
||||
|
||||
return writeShellExports(vars)
|
||||
}
|
||||
|
||||
func writeShellExports(vars []string) error {
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
for _, kv := range vars {
|
||||
// Split on first '=' so we can quote only the value, handling paths with spaces.
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
if _, err := fmt.Fprintf(w, "export %s=%q\n", k, v); err != nil {
|
||||
return fmt.Errorf("write env var: %w", err)
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func writeGitHubEnv(vars []string) error {
|
||||
ghEnvFile := os.Getenv("GITHUB_ENV")
|
||||
if ghEnvFile == "" {
|
||||
return fmt.Errorf("$GITHUB_ENV is not set — are you running in GitHub Actions?")
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(ghEnvFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open $GITHUB_ENV file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
for _, v := range vars {
|
||||
if _, err := fmt.Fprintf(w, "%s\n", v); err != nil {
|
||||
return fmt.Errorf("write to $GITHUB_ENV: %w", err)
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package proxy
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func NewProxyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "proxy",
|
||||
Short: "Manage the persistent PMG proxy server",
|
||||
}
|
||||
cmd.AddCommand(newStartCommand())
|
||||
cmd.AddCommand(newStopCommand())
|
||||
cmd.AddCommand(newEnvCommand())
|
||||
cmd.AddCommand(newStatusCommand())
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/pmg/analyzer"
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/flows"
|
||||
"github.com/safedep/pmg/internal/proxystate"
|
||||
pmgproxy "github.com/safedep/pmg/proxy"
|
||||
"github.com/safedep/pmg/proxy/certmanager"
|
||||
"github.com/safedep/pmg/proxy/interceptors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newStartCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start the persistent PMG proxy server (runs in foreground)",
|
||||
RunE: runStart,
|
||||
}
|
||||
}
|
||||
|
||||
func runStart(_ *cobra.Command, _ []string) error {
|
||||
cfg := config.Get()
|
||||
statePath := proxystate.StatePath(cfg.ConfigDir())
|
||||
|
||||
if existing, err := proxystate.Read(statePath); err == nil && existing.IsRunning() {
|
||||
return fmt.Errorf("proxy already running (pid %d, addr %s) — run 'pmg proxy stop' first", existing.PID, existing.Addr)
|
||||
}
|
||||
|
||||
caCertPath := filepath.Join(cfg.ConfigDir(), "proxy-ca.pem")
|
||||
caCert, _, err := flows.SetupCACertificate(cfg.ConfigDir(), caCertPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setup CA certificate: %w", err)
|
||||
}
|
||||
|
||||
certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, certmanager.DefaultCertManagerConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create certificate manager: %w", err)
|
||||
}
|
||||
|
||||
malysisAnalyzer, err := analyzer.NewMalysisAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create analyzer: %w", err)
|
||||
}
|
||||
|
||||
cache := interceptors.NewInMemoryAnalysisCache()
|
||||
stats := interceptors.NewAnalysisStatsCollector()
|
||||
confirmationChan := make(chan *interceptors.ConfirmationRequest, 100)
|
||||
go autoBlockConfirmations(confirmationChan)
|
||||
|
||||
factory := interceptors.NewInterceptorFactory(
|
||||
malysisAnalyzer, cache, stats, confirmationChan, interceptors.InterceptorContext{},
|
||||
)
|
||||
|
||||
var interceptorList []pmgproxy.Interceptor
|
||||
for _, eco := range interceptors.SupportedEcosystems() {
|
||||
i, ferr := factory.CreateInterceptor(eco)
|
||||
if ferr != nil {
|
||||
return fmt.Errorf("create interceptor for %s: %w", eco.String(), ferr)
|
||||
}
|
||||
interceptorList = append(interceptorList, i)
|
||||
}
|
||||
interceptorList = append(interceptorList, interceptors.NewAuditLoggerInterceptor())
|
||||
|
||||
proxyConfig := pmgproxy.DefaultProxyConfig()
|
||||
proxyConfig.CertManager = certMgr
|
||||
proxyConfig.Interceptors = interceptorList
|
||||
|
||||
server, err := pmgproxy.NewProxyServer(proxyConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create proxy server: %w", err)
|
||||
}
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
return fmt.Errorf("start proxy server: %w", err)
|
||||
}
|
||||
|
||||
state := proxystate.State{
|
||||
PID: os.Getpid(),
|
||||
Addr: server.Address(),
|
||||
CACertPath: caCertPath,
|
||||
}
|
||||
if err := proxystate.Write(statePath, state); err != nil {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Stop(stopCtx)
|
||||
return fmt.Errorf("write proxy state: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("PMG persistent proxy running on %s (pid %d)", state.Addr, state.PID)
|
||||
if _, err := fmt.Fprintf(os.Stderr, "PMG proxy running on %s\nRun: eval $(pmg proxy env) # or: pmg proxy env --gha\n", state.Addr); err != nil {
|
||||
log.Warnf("failed to write startup message: %v", err)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
close(confirmationChan)
|
||||
_ = proxystate.Remove(statePath)
|
||||
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return server.Stop(stopCtx)
|
||||
}
|
||||
|
||||
// autoBlockConfirmations drains the confirmation channel and always denies,
|
||||
// appropriate for non-interactive CI/CD environments.
|
||||
func autoBlockConfirmations(ch chan *interceptors.ConfirmationRequest) {
|
||||
for req := range ch {
|
||||
log.Warnf("Persistent proxy: auto-blocking suspicious package %s", req.PackageVersion.GetPackage().GetName())
|
||||
req.ResponseChan <- false
|
||||
close(req.ResponseChan)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/proxystate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newStatusCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show the status of the persistent PMG proxy server",
|
||||
RunE: runStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func runStatus(_ *cobra.Command, _ []string) error {
|
||||
cfg := config.Get()
|
||||
statePath := proxystate.StatePath(cfg.ConfigDir())
|
||||
|
||||
state, err := proxystate.Read(statePath)
|
||||
if err != nil {
|
||||
if _, werr := fmt.Fprintln(os.Stdout, "PMG proxy: not running (no state file)"); werr != nil {
|
||||
return werr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if state.IsRunning() {
|
||||
_, err = fmt.Fprintf(os.Stdout, "PMG proxy: running (pid %d, addr %s, ca %s)\n",
|
||||
state.PID, state.Addr, state.CACertPath)
|
||||
} else {
|
||||
_, err = fmt.Fprintf(os.Stdout, "PMG proxy: stopped (stale state for pid %d — run 'pmg proxy stop' to clean up)\n", state.PID)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/proxystate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newStopCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the running persistent PMG proxy server",
|
||||
RunE: runStop,
|
||||
}
|
||||
}
|
||||
|
||||
func runStop(_ *cobra.Command, _ []string) error {
|
||||
cfg := config.Get()
|
||||
statePath := proxystate.StatePath(cfg.ConfigDir())
|
||||
|
||||
state, err := proxystate.Read(statePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no proxy state found — is the proxy running? (%w)", err)
|
||||
}
|
||||
|
||||
if !state.IsRunning() {
|
||||
_ = proxystate.Remove(statePath)
|
||||
return fmt.Errorf("proxy process (pid %d) is not running; state file cleaned up", state.PID)
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(state.PID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find proxy process (pid %d): %w", state.PID, err)
|
||||
}
|
||||
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("send SIGTERM to proxy (pid %d): %w", state.PID, err)
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(os.Stdout, "Sent SIGTERM to PMG proxy (pid %d, addr %s)\n", state.PID, state.Addr); err != nil {
|
||||
return fmt.Errorf("write stop message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user