Skip to content
Merged
Show file tree
Hide file tree
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 Mar 7, 2026
e66e759
Speed up fork-from-running integration tests
sjmiller609 Mar 8, 2026
4433590
Reduce fork test agent wait latency
sjmiller609 Mar 8, 2026
b708aef
feat(instances): add optional egress MITM proxy secret rewriting
sjmiller609 Mar 8, 2026
d8a64b9
test(instances): use prewarmed nginx image in egress proxy integratio…
sjmiller609 Mar 8, 2026
e3beda6
feat(egress-proxy): source secret rewrites from instance env
sjmiller609 Mar 8, 2026
d56d8b5
chore: remove agents notes from this PR
sjmiller609 Mar 8, 2026
cbe7082
test(ci): mirror API/integration image refs and prewarm missing images
sjmiller609 Mar 8, 2026
c58e231
Add egress proxy enforcement modes with strict default
sjmiller609 Mar 8, 2026
2d76140
Fix CI image pull flakes in API and e2e install tests
sjmiller609 Mar 8, 2026
63fa947
Add per-env domain-gated HTTPS secret substitution
sjmiller609 Mar 9, 2026
a9231cd
Fix review issues in cert cache and prewarm logging
sjmiller609 Mar 9, 2026
26c4a29
Fix follow-up proxy review findings
sjmiller609 Mar 9, 2026
3266c10
Address latest bugbot follow-ups
sjmiller609 Mar 9, 2026
060e1d1
Harden HTTP proxy replacement destination handling
sjmiller609 Mar 9, 2026
e648719
Merge remote-tracking branch 'origin/main' into feature/egress-mitm-p…
sjmiller609 Mar 10, 2026
2ad0511
Merge remote-tracking branch 'origin/main' into feature/egress-mitm-p…
sjmiller609 Mar 10, 2026
717e16f
Fix restore proxy config refresh and async image queue coverage
sjmiller609 Mar 10, 2026
3ef9157
Remove unused egress proxy host normalizer
sjmiller609 Mar 10, 2026
8c3775f
Format instances types after merge
sjmiller609 Mar 10, 2026
e6d841e
Merge remote-tracking branch 'origin/main' into feature/egress-mitm-p…
sjmiller609 Mar 16, 2026
a25a59b
refactor egress API to policy + credentials and fix restore regressions
sjmiller609 Mar 16, 2026
865b50c
Fix open review findings for create validation and proxy errors
sjmiller609 Mar 17, 2026
2dd5cfc
Align egress credential injection with policy model
sjmiller609 Mar 18, 2026
2f6d425
Merge remote-tracking branch 'origin/main' into feature/egress-mitm-p…
sjmiller609 Mar 18, 2026
f342efb
Fix API instance test after merging main
sjmiller609 Mar 18, 2026
3ba6d54
Merge branch 'main' into feature/egress-mitm-proxy-secret-rewrite
sjmiller609 Mar 19, 2026
3f15717
Surface egress proxy CA cert setup failures as hard errors
sjmiller609 Mar 19, 2026
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
9 changes: 9 additions & 0 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
if request.Body.Network != nil && request.Body.Network.Enabled != nil {
networkEnabled = *request.Body.Network.Enabled
}
var egressProxyConfig *instances.EgressProxyConfig
if request.Body.EgressProxy != nil {
enabled := request.Body.EgressProxy.Enabled != nil && *request.Body.EgressProxy.Enabled
egressProxyConfig = &instances.EgressProxyConfig{Enabled: enabled}
if request.Body.EgressProxy.MockEnvVars != nil {
egressProxyConfig.MockEnvVars = append([]string(nil), (*request.Body.EgressProxy.MockEnvVars)...)
}
}

// Parse network bandwidth limits (0 = auto)
// Supports both bit-based (e.g., "1Gbps") and byte-based (e.g., "125MB/s") formats
Expand Down Expand Up @@ -255,6 +263,7 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
Env: env,
Metadata: metadata,
NetworkEnabled: networkEnabled,
EgressProxy: egressProxyConfig,
Devices: deviceRefs,
Volumes: volumes,
Hypervisor: hvType,
Expand Down
41 changes: 41 additions & 0 deletions cmd/api/api/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,47 @@ func TestCreateInstance_OmittedHotplugSizeDefaultsToZero(t *testing.T) {
assert.Equal(t, int64(0), int64(hotplugBytes), "response should report zero hotplug_size when omitted")
}

func TestCreateInstance_MapsEgressProxyMockEnvVars(t *testing.T) {
t.Parallel()
svc := newTestService(t)

origMgr := svc.InstanceManager
mockMgr := &captureCreateManager{Manager: origMgr}
svc.InstanceManager = mockMgr

enabled := true
mockEnvVars := []string{"OUTBOUND_OPENAI_KEY", "GITHUB_TOKEN"}
env := map[string]string{
"OUTBOUND_OPENAI_KEY": "real-openai-key-123",
"GITHUB_TOKEN": "real-gh-token-456",
}

resp, err := svc.CreateInstance(ctx(), oapi.CreateInstanceRequestObject{
Body: &oapi.CreateInstanceRequest{
Name: "test-egress-proxy-mock-env-vars",
Image: "docker.io/library/alpine:latest",
Env: &env,
EgressProxy: &struct {
Enabled *bool `json:"enabled,omitempty"`
MockEnvVars *[]string `json:"mock_env_vars,omitempty"`
}{
Enabled: &enabled,
MockEnvVars: &mockEnvVars,
},
},
})
require.NoError(t, err)
_, ok := resp.(oapi.CreateInstance201JSONResponse)
require.True(t, ok, "expected 201 response")

require.NotNil(t, mockMgr.lastReq)
require.NotNil(t, mockMgr.lastReq.EgressProxy)
assert.True(t, mockMgr.lastReq.EgressProxy.Enabled)
assert.Equal(t, []string{"OUTBOUND_OPENAI_KEY", "GITHUB_TOKEN"}, mockMgr.lastReq.EgressProxy.MockEnvVars)
assert.Equal(t, "real-openai-key-123", mockMgr.lastReq.Env["OUTBOUND_OPENAI_KEY"])
assert.Equal(t, "real-gh-token-456", mockMgr.lastReq.Env["GITHUB_TOKEN"])
}

func TestForkInstance_Success(t *testing.T) {
t.Parallel()
svc := newTestService(t)
Expand Down
32 changes: 32 additions & 0 deletions lib/egressproxy/README.md
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).
162 changes: 162 additions & 0 deletions lib/egressproxy/cert.go
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
}
Comment thread
sjmiller609 marked this conversation as resolved.
Outdated
93 changes: 93 additions & 0 deletions lib/egressproxy/enforce_linux.go
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)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
11 changes: 11 additions & 0 deletions lib/egressproxy/enforce_other.go
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
}
Loading
Loading