-
Notifications
You must be signed in to change notification settings - Fork 7
Optional VM egress MITM proxy with mock-secret header rewriting #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
e28a8f6
Stabilize integration network and ingress test flakes
sjmiller609 e66e759
Speed up fork-from-running integration tests
sjmiller609 4433590
Reduce fork test agent wait latency
sjmiller609 b708aef
feat(instances): add optional egress MITM proxy secret rewriting
sjmiller609 d8a64b9
test(instances): use prewarmed nginx image in egress proxy integratio…
sjmiller609 e3beda6
feat(egress-proxy): source secret rewrites from instance env
sjmiller609 d56d8b5
chore: remove agents notes from this PR
sjmiller609 cbe7082
test(ci): mirror API/integration image refs and prewarm missing images
sjmiller609 c58e231
Add egress proxy enforcement modes with strict default
sjmiller609 2d76140
Fix CI image pull flakes in API and e2e install tests
sjmiller609 63fa947
Add per-env domain-gated HTTPS secret substitution
sjmiller609 a9231cd
Fix review issues in cert cache and prewarm logging
sjmiller609 26c4a29
Fix follow-up proxy review findings
sjmiller609 3266c10
Address latest bugbot follow-ups
sjmiller609 060e1d1
Harden HTTP proxy replacement destination handling
sjmiller609 e648719
Merge remote-tracking branch 'origin/main' into feature/egress-mitm-p…
sjmiller609 2ad0511
Merge remote-tracking branch 'origin/main' into feature/egress-mitm-p…
sjmiller609 717e16f
Fix restore proxy config refresh and async image queue coverage
sjmiller609 3ef9157
Remove unused egress proxy host normalizer
sjmiller609 8c3775f
Format instances types after merge
sjmiller609 e6d841e
Merge remote-tracking branch 'origin/main' into feature/egress-mitm-p…
sjmiller609 a25a59b
refactor egress API to policy + credentials and fix restore regressions
sjmiller609 865b50c
Fix open review findings for create validation and proxy errors
sjmiller609 2dd5cfc
Align egress credential injection with policy model
sjmiller609 2f6d425
Merge remote-tracking branch 'origin/main' into feature/egress-mitm-p…
sjmiller609 f342efb
Fix API instance test after merging main
sjmiller609 3ba6d54
Merge branch 'main' into feature/egress-mitm-proxy-secret-rewrite
sjmiller609 3f15717
Surface egress proxy CA cert setup failures as hard errors
sjmiller609 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # Egress Proxy (Mock Secret Substitution) | ||
|
|
||
| This module provides an optional, default-off networking mode for VM egress. | ||
|
|
||
| When enabled for an instance, hypeman does three things: | ||
|
|
||
| 1. It starts (or reuses) a host-side HTTP/HTTPS MITM proxy bound to the VM bridge gateway. | ||
| 2. It injects proxy environment variables into the guest (`HTTP_PROXY` / `HTTPS_PROXY`) and installs the proxy CA certificate in the guest trust store. | ||
| 3. It enforces policy on the host so direct outbound TCP traffic on ports `80` and `443` from that VM's TAP interface is rejected unless it is going to the bridge gateway (the proxy). | ||
|
|
||
| ## Secret substitution flow | ||
|
|
||
| - API callers provide real secret values in instance `env`. | ||
| - Per instance, `egress_proxy.mock_env_vars` lists which env var names should be mocked. | ||
| - Inside the VM, each listed env var is rewritten to `mock-<ENV_VAR_NAME>` (for example `mock-OUTBOUND_OPENAI_KEY`). | ||
| - For each outbound HTTP request (including HTTPS requests after MITM decryption), the proxy scans every HTTP header value. | ||
| - Any occurrence of a configured mock value is replaced with the real value loaded from the instance's stored `env`. | ||
| - The modified request is then forwarded upstream. | ||
|
|
||
| This keeps real secrets out of the VM while still allowing authenticated egress requests. | ||
|
|
||
| ## Security behavior | ||
|
|
||
| - Real secret values are persisted in the normal instance `env` metadata, which is already host-side state. | ||
| - TLS interception requires guest trust of the proxy CA; hypeman installs this CA in the guest when proxy mode is enabled. | ||
| - Egress enforcement is applied per instance TAP device and removed when the instance stops/standbys/deletes. | ||
|
|
||
| ## Limits of enforcement | ||
|
|
||
| - Enforcement currently targets HTTP/HTTPS default ports (`80` and `443`). | ||
| - Non-HTTP protocols or custom ports are not rewritten. | ||
| - Header replacement is applied to HTTP headers only (not request/response bodies). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| package egressproxy | ||
|
|
||
| import ( | ||
| "crypto/rand" | ||
| "crypto/rsa" | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "crypto/x509/pkix" | ||
| "encoding/pem" | ||
| "fmt" | ||
| "math/big" | ||
| "net" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "time" | ||
| ) | ||
|
|
||
| func loadOrCreateCA(dataDir string) (*x509.Certificate, *rsa.PrivateKey, string, error) { | ||
| dir := filepath.Join(dataDir, "egressproxy") | ||
| certPath := filepath.Join(dir, "ca.crt") | ||
| keyPath := filepath.Join(dir, "ca.key") | ||
|
|
||
| certPEM, certErr := os.ReadFile(certPath) | ||
| keyPEM, keyErr := os.ReadFile(keyPath) | ||
| if certErr == nil && keyErr == nil { | ||
| cert, key, err := parseCA(certPEM, keyPEM) | ||
| if err == nil { | ||
| return cert, key, string(certPEM), nil | ||
| } | ||
| } | ||
|
|
||
| if err := os.MkdirAll(dir, 0755); err != nil { | ||
| return nil, nil, "", fmt.Errorf("create egress proxy cert dir: %w", err) | ||
| } | ||
|
|
||
| caKey, err := rsa.GenerateKey(rand.Reader, 2048) | ||
| if err != nil { | ||
| return nil, nil, "", fmt.Errorf("generate egress proxy CA key: %w", err) | ||
| } | ||
|
|
||
| serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | ||
| if err != nil { | ||
| return nil, nil, "", fmt.Errorf("generate CA serial: %w", err) | ||
| } | ||
|
|
||
| now := time.Now() | ||
| tmpl := &x509.Certificate{ | ||
| SerialNumber: serialNumber, | ||
| Subject: pkix.Name{ | ||
| CommonName: "hypeman-egress-proxy-ca", | ||
| Organization: []string{"hypeman"}, | ||
| }, | ||
| NotBefore: now.Add(-1 * time.Hour), | ||
| NotAfter: now.Add(10 * 365 * 24 * time.Hour), | ||
| KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | ||
| BasicConstraintsValid: true, | ||
| IsCA: true, | ||
| MaxPathLen: 1, | ||
| } | ||
|
|
||
| certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &caKey.PublicKey, caKey) | ||
| if err != nil { | ||
| return nil, nil, "", fmt.Errorf("create egress proxy CA cert: %w", err) | ||
| } | ||
|
|
||
| certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | ||
| keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(caKey)}) | ||
|
|
||
| if err := os.WriteFile(certPath, certPEM, 0644); err != nil { | ||
| return nil, nil, "", fmt.Errorf("write egress proxy CA cert: %w", err) | ||
| } | ||
| if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { | ||
| return nil, nil, "", fmt.Errorf("write egress proxy CA key: %w", err) | ||
| } | ||
|
|
||
| cert, key, err := parseCA(certPEM, keyPEM) | ||
| if err != nil { | ||
| return nil, nil, "", err | ||
| } | ||
|
|
||
| return cert, key, string(certPEM), nil | ||
| } | ||
|
|
||
| func parseCA(certPEM, keyPEM []byte) (*x509.Certificate, *rsa.PrivateKey, error) { | ||
| certBlock, _ := pem.Decode(certPEM) | ||
| if certBlock == nil { | ||
| return nil, nil, fmt.Errorf("parse egress proxy CA cert PEM") | ||
| } | ||
| cert, err := x509.ParseCertificate(certBlock.Bytes) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("parse egress proxy CA cert: %w", err) | ||
| } | ||
| keyBlock, _ := pem.Decode(keyPEM) | ||
| if keyBlock == nil { | ||
| return nil, nil, fmt.Errorf("parse egress proxy CA key PEM") | ||
| } | ||
| key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("parse egress proxy CA key: %w", err) | ||
| } | ||
| return cert, key, nil | ||
| } | ||
|
|
||
| func signHostCertificate(caCert *x509.Certificate, caKey *rsa.PrivateKey, host string) (*tls.Certificate, error) { | ||
| leafKey, err := rsa.GenerateKey(rand.Reader, 2048) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("generate leaf key: %w", err) | ||
| } | ||
|
|
||
| serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("generate leaf serial: %w", err) | ||
| } | ||
|
|
||
| now := time.Now() | ||
| tmpl := &x509.Certificate{ | ||
| SerialNumber: serialNumber, | ||
| Subject: pkix.Name{ | ||
| CommonName: host, | ||
| }, | ||
| NotBefore: now.Add(-1 * time.Hour), | ||
| NotAfter: now.Add(24 * time.Hour), | ||
| KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | ||
| ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | ||
| BasicConstraintsValid: true, | ||
| } | ||
|
|
||
| if ip := net.ParseIP(host); ip != nil { | ||
| tmpl.IPAddresses = []net.IP{ip} | ||
| } else { | ||
| tmpl.DNSNames = []string{host} | ||
| } | ||
|
|
||
| leafDER, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &leafKey.PublicKey, caKey) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("sign leaf cert: %w", err) | ||
| } | ||
|
|
||
| leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}) | ||
| caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}) | ||
| keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(leafKey)}) | ||
|
|
||
| tlsCert, err := tls.X509KeyPair(append(leafPEM, caPEM...), keyPEM) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("build leaf keypair: %w", err) | ||
| } | ||
| if len(tlsCert.Certificate) > 0 { | ||
| if leaf, parseErr := x509.ParseCertificate(tlsCert.Certificate[0]); parseErr == nil { | ||
| tlsCert.Leaf = leaf | ||
| } | ||
| } | ||
| return &tlsCert, nil | ||
| } | ||
|
|
||
| func normalizeHost(rawHost string) string { | ||
| host := strings.TrimSpace(rawHost) | ||
| if h, _, err := net.SplitHostPort(host); err == nil { | ||
| return h | ||
| } | ||
| return host | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| //go:build linux | ||
|
|
||
| package egressproxy | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os/exec" | ||
| "strings" | ||
| ) | ||
|
|
||
| func applyEgressEnforcement(instanceID, tapDevice, gatewayIP string, proxyPort int) error { | ||
| if instanceID == "" || tapDevice == "" || gatewayIP == "" || proxyPort <= 0 { | ||
| return fmt.Errorf("invalid egress enforcement inputs") | ||
| } | ||
|
|
||
| comment80 := enforcementComment(instanceID, "80") | ||
| comment443 := enforcementComment(instanceID, "443") | ||
|
|
||
| // Clean old rules first so updates are idempotent (tap changes across restarts). | ||
| _ = removeRuleByComment(comment80) | ||
| _ = removeRuleByComment(comment443) | ||
|
|
||
| if err := insertRejectRule(tapDevice, gatewayIP, 80, comment80); err != nil { | ||
| return fmt.Errorf("insert port 80 egress enforcement: %w", err) | ||
| } | ||
| if err := insertRejectRule(tapDevice, gatewayIP, 443, comment443); err != nil { | ||
| _ = removeRuleByComment(comment80) | ||
| return fmt.Errorf("insert port 443 egress enforcement: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func removeEgressEnforcement(instanceID string) error { | ||
| if instanceID == "" { | ||
| return nil | ||
| } | ||
| comment80 := enforcementComment(instanceID, "80") | ||
| comment443 := enforcementComment(instanceID, "443") | ||
| _ = removeRuleByComment(comment80) | ||
| _ = removeRuleByComment(comment443) | ||
| return nil | ||
| } | ||
|
|
||
| func insertRejectRule(tapDevice, gatewayIP string, port int, comment string) error { | ||
| cmd := exec.Command( | ||
| "iptables", "-I", "FORWARD", "1", | ||
| "-i", tapDevice, | ||
| "-p", "tcp", | ||
| "--dport", fmt.Sprintf("%d", port), | ||
| "!", "-d", gatewayIP, | ||
| "-m", "comment", "--comment", comment, | ||
| "-j", "REJECT", | ||
| ) | ||
| if out, err := cmd.CombinedOutput(); err != nil { | ||
| return fmt.Errorf("iptables insert failed: %w: %s", err, strings.TrimSpace(string(out))) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func removeRuleByComment(comment string) error { | ||
| listCmd := exec.Command("iptables", "-L", "FORWARD", "--line-numbers", "-n") | ||
| output, err := listCmd.Output() | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| var ruleNums []string | ||
| for _, line := range strings.Split(string(output), "\n") { | ||
| if !strings.Contains(line, comment) { | ||
| continue | ||
| } | ||
| fields := strings.Fields(line) | ||
| if len(fields) == 0 { | ||
| continue | ||
| } | ||
| ruleNums = append(ruleNums, fields[0]) | ||
| } | ||
|
|
||
| for i := len(ruleNums) - 1; i >= 0; i-- { | ||
| delCmd := exec.Command("iptables", "-D", "FORWARD", ruleNums[i]) | ||
| _ = delCmd.Run() | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func enforcementComment(instanceID, suffix string) string { | ||
| short := instanceID | ||
| if len(short) > 8 { | ||
| short = short[:8] | ||
| } | ||
| return fmt.Sprintf("hypeman-egress-%s-%s", short, suffix) | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| //go:build !linux | ||
|
|
||
| package egressproxy | ||
|
|
||
| func applyEgressEnforcement(instanceID, tapDevice, gatewayIP string, proxyPort int) error { | ||
| return nil | ||
| } | ||
|
|
||
| func removeEgressEnforcement(instanceID string) error { | ||
| return nil | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.