Skip to content
Draft
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
77 changes: 77 additions & 0 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,83 @@ func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInst
return response, nil
}

// UpdateInstanceCredentials replaces credential brokering policies for an instance
func (s *ApiService) UpdateInstanceCredentials(ctx context.Context, request oapi.UpdateInstanceCredentialsRequestObject) (oapi.UpdateInstanceCredentialsResponseObject, error) {
inst := mw.GetResolvedInstance[instances.Instance](ctx)
if inst == nil {
return oapi.UpdateInstanceCredentials500JSONResponse{
Code: "internal_error",
Message: "resource not resolved",
}, nil
}
log := logger.FromContext(ctx)

if request.Body == nil {
return oapi.UpdateInstanceCredentials400JSONResponse{
Code: "invalid_request",
Message: "request body is required",
}, nil
}

// Convert OAPI credentials to domain type
var credentials map[string]instances.CredentialPolicy
if request.Body.Credentials != nil {
credentials = make(map[string]instances.CredentialPolicy, len(*request.Body.Credentials))
for credentialName, credential := range *request.Body.Credentials {
policy := instances.CredentialPolicy{
Source: instances.CredentialSource{
Env: credential.Source.Env,
},
Inject: make([]instances.CredentialInjectRule, 0, len(credential.Inject)),
}
for _, inject := range credential.Inject {
rule := instances.CredentialInjectRule{
As: instances.CredentialInjectAs{
Header: inject.As.Header,
Format: inject.As.Format,
},
}
if inject.Hosts != nil {
rule.Hosts = append([]string(nil), (*inject.Hosts)...)
}
policy.Inject = append(policy.Inject, rule)
}
credentials[credentialName] = policy
}
}

env := make(map[string]string)
if request.Body.Env != nil {
env = *request.Body.Env
}

result, err := s.InstanceManager.UpdateCredentials(ctx, inst.Id, instances.UpdateCredentialsRequest{
Credentials: credentials,
Env: env,
})
if err != nil {
switch {
case errors.Is(err, instances.ErrNotFound):
return oapi.UpdateInstanceCredentials404JSONResponse{
Code: "not_found",
Message: "instance not found",
}, nil
case errors.Is(err, instances.ErrInvalidRequest):
return oapi.UpdateInstanceCredentials400JSONResponse{
Code: "invalid_request",
Message: err.Error(),
}, nil
default:
log.ErrorContext(ctx, "failed to update credentials", "error", err)
return oapi.UpdateInstanceCredentials500JSONResponse{
Code: "internal_error",
Message: "failed to update credentials",
}, nil
}
}
return oapi.UpdateInstanceCredentials200JSONResponse(instanceToOAPI(*result)), nil
}

// AttachVolume attaches a volume to an instance (not yet implemented)
func (s *ApiService) AttachVolume(ctx context.Context, request oapi.AttachVolumeRequestObject) (oapi.AttachVolumeResponseObject, error) {
return oapi.AttachVolume500JSONResponse{
Expand Down
4 changes: 4 additions & 0 deletions lib/egressproxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ This keeps real secrets out of the VM while still allowing authenticated egress
- Egress enforcement is applied per instance TAP device and removed when the instance stops/standbys/deletes.
- Enforcement intentionally targets TCP egress only. DNS/other non-TCP traffic is not rewritten and is not blocked by `all` mode.

## Credential rotation

Credentials can be updated at runtime via `PATCH /instances/{id}/credentials` without restarting the VM or touching the guest. The endpoint uses merge semantics: only credentials included in the request are added or updated by name; credentials not mentioned are left unchanged. The proxy picks up new values immediately for running instances. For stopped or standby instances, the updated config takes effect on next start/restore.

## Limits of enforcement

- Header injection is applied to HTTP headers only (not request/response bodies).
Expand Down
21 changes: 21 additions & 0 deletions lib/egressproxy/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,27 @@ func compileHeaderInjectRules(cfgRules []HeaderInjectRuleConfig) ([]headerInject
return out, nil
}

// UpdateInstancePolicy atomically replaces the header inject rules for an
// already-registered instance without touching iptables enforcement rules.
// Returns ErrInstanceNotRegistered if the instance has no active policy.
func (s *Service) UpdateInstancePolicy(instanceID string, rules []HeaderInjectRuleConfig) error {
compiled, err := compileHeaderInjectRules(rules)
if err != nil {
return err
}

s.mu.Lock()
defer s.mu.Unlock()

sourceIP, ok := s.sourceIPByInstance[instanceID]
if !ok {
return ErrInstanceNotRegistered
}

s.policiesBySourceIP[sourceIP] = sourcePolicy{headerInjectRules: compiled}
return nil
}

func (s *Service) UnregisterInstance(_ context.Context, instanceID string) {
s.mu.Lock()
sourceIP, ok := s.sourceIPByInstance[instanceID]
Expand Down
3 changes: 2 additions & 1 deletion lib/egressproxy/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const (
)

var (
ErrGatewayMismatch = errors.New("egress proxy already initialized with different gateway")
ErrGatewayMismatch = errors.New("egress proxy already initialized with different gateway")
ErrInstanceNotRegistered = errors.New("instance not registered with egress proxy")
)

// InstanceConfig defines per-instance proxy behavior.
Expand Down
175 changes: 175 additions & 0 deletions lib/egressproxy/update_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package egressproxy

import (
"net/http"
"testing"

"github.com/stretchr/testify/require"
)

func TestUpdateInstancePolicy_ReplacesRules(t *testing.T) {
t.Parallel()

svc := &Service{
policiesBySourceIP: map[string]sourcePolicy{
"10.0.0.2": {
headerInjectRules: []headerInjectRule{
{headerName: "Authorization", headerValue: "Bearer old-key"},
},
},
},
sourceIPByInstance: map[string]string{
"inst-1": "10.0.0.2",
},
}

err := svc.UpdateInstancePolicy("inst-1", []HeaderInjectRuleConfig{
{HeaderName: "Authorization", HeaderValue: "Bearer new-key", AllowedDomains: []string{"api.openai.com"}},
})
require.NoError(t, err)

// Verify the new rules are applied
hdr := http.Header{}
svc.applyHeaderInjections("10.0.0.2", "api.openai.com", hdr, true)
require.Equal(t, "Bearer new-key", hdr.Get("Authorization"))

// Verify domain scoping works (should not inject for other domains)
hdr2 := http.Header{}
svc.applyHeaderInjections("10.0.0.2", "api.github.com", hdr2, true)
require.Empty(t, hdr2.Get("Authorization"))
}

func TestUpdateInstancePolicy_ClearsRulesWithEmptySlice(t *testing.T) {
t.Parallel()

svc := &Service{
policiesBySourceIP: map[string]sourcePolicy{
"10.0.0.2": {
headerInjectRules: []headerInjectRule{
{headerName: "Authorization", headerValue: "Bearer old-key"},
},
},
},
sourceIPByInstance: map[string]string{
"inst-1": "10.0.0.2",
},
}

err := svc.UpdateInstancePolicy("inst-1", []HeaderInjectRuleConfig{})
require.NoError(t, err)

hdr := http.Header{}
svc.applyHeaderInjections("10.0.0.2", "api.openai.com", hdr, true)
require.Empty(t, hdr.Get("Authorization"))
}

func TestUpdateInstancePolicy_ErrorsForUnregisteredInstance(t *testing.T) {
t.Parallel()

svc := &Service{
policiesBySourceIP: map[string]sourcePolicy{},
sourceIPByInstance: map[string]string{},
}

err := svc.UpdateInstancePolicy("nonexistent", []HeaderInjectRuleConfig{
{HeaderName: "Authorization", HeaderValue: "Bearer key"},
})
require.ErrorIs(t, err, ErrInstanceNotRegistered)
}

func TestUpdateInstancePolicy_IsIdempotent(t *testing.T) {
t.Parallel()

svc := &Service{
policiesBySourceIP: map[string]sourcePolicy{
"10.0.0.2": {},
},
sourceIPByInstance: map[string]string{
"inst-1": "10.0.0.2",
},
}

rules := []HeaderInjectRuleConfig{
{HeaderName: "Authorization", HeaderValue: "Bearer same-key"},
}

// Call twice — should produce the same result
require.NoError(t, svc.UpdateInstancePolicy("inst-1", rules))
require.NoError(t, svc.UpdateInstancePolicy("inst-1", rules))

hdr := http.Header{}
svc.applyHeaderInjections("10.0.0.2", "api.example.com", hdr, true)
require.Equal(t, "Bearer same-key", hdr.Get("Authorization"))
}

func TestUpdateInstancePolicy_DoesNotAffectOtherInstances(t *testing.T) {
t.Parallel()

matchers1, _ := compileDomainMatchers([]string{"api.openai.com"})
svc := &Service{
policiesBySourceIP: map[string]sourcePolicy{
"10.0.0.2": {
headerInjectRules: []headerInjectRule{
{headerName: "Authorization", headerValue: "Bearer inst1-key", domainMatchers: matchers1},
},
},
"10.0.0.3": {
headerInjectRules: []headerInjectRule{
{headerName: "Authorization", headerValue: "Bearer inst2-key"},
},
},
},
sourceIPByInstance: map[string]string{
"inst-1": "10.0.0.2",
"inst-2": "10.0.0.3",
},
}

// Update inst-1 only
err := svc.UpdateInstancePolicy("inst-1", []HeaderInjectRuleConfig{
{HeaderName: "Authorization", HeaderValue: "Bearer inst1-new-key"},
})
require.NoError(t, err)

// inst-2 should be unaffected
hdr := http.Header{}
svc.applyHeaderInjections("10.0.0.3", "api.example.com", hdr, true)
require.Equal(t, "Bearer inst2-key", hdr.Get("Authorization"))
}

func TestUpdateInstancePolicy_RegisterThenUpdate(t *testing.T) {
t.Parallel()

// Simulate a service with a pre-registered instance (without starting a real listener)
matchers, err := compileDomainMatchers([]string{"api.openai.com"})
require.NoError(t, err)

svc := &Service{
policiesBySourceIP: map[string]sourcePolicy{
"10.0.0.2": {
headerInjectRules: []headerInjectRule{
{headerName: "Authorization", headerValue: "Bearer original-key", domainMatchers: matchers},
},
},
},
sourceIPByInstance: map[string]string{
"inst-1": "10.0.0.2",
},
}

// Verify original key
hdr := http.Header{}
svc.applyHeaderInjections("10.0.0.2", "api.openai.com", hdr, true)
require.Equal(t, "Bearer original-key", hdr.Get("Authorization"))

// Update the policy with a rotated key
err = svc.UpdateInstancePolicy("inst-1", []HeaderInjectRuleConfig{
{HeaderName: "Authorization", HeaderValue: "Bearer rotated-key", AllowedDomains: []string{"api.openai.com"}},
})
require.NoError(t, err)

// Verify rotated key is used
hdr2 := http.Header{}
svc.applyHeaderInjections("10.0.0.2", "api.openai.com", hdr2, true)
require.Equal(t, "Bearer rotated-key", hdr2.Get("Authorization"))
}
9 changes: 9 additions & 0 deletions lib/instances/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type Manager interface {
StartInstance(ctx context.Context, id string, req StartInstanceRequest) (*Instance, error)
StreamInstanceLogs(ctx context.Context, id string, tail int, follow bool, source LogSource) (<-chan string, error)
RotateLogs(ctx context.Context, maxBytes int64, maxFiles int) error
UpdateCredentials(ctx context.Context, id string, req UpdateCredentialsRequest) (*Instance, error)
AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error)
DetachVolume(ctx context.Context, id string, volumeId string) (*Instance, error)
// ListInstanceAllocations returns resource allocations for all instances.
Expand Down Expand Up @@ -441,6 +442,14 @@ func (m *manager) RotateLogs(ctx context.Context, maxBytes int64, maxFiles int)
return lastErr
}

// UpdateCredentials replaces the credential policies for an instance.
func (m *manager) UpdateCredentials(ctx context.Context, id string, req UpdateCredentialsRequest) (*Instance, error) {
lock := m.getInstanceLock(id)
lock.Lock()
defer lock.Unlock()
return m.updateCredentials(ctx, id, req)
}

// AttachVolume attaches a volume to an instance (not yet implemented)
func (m *manager) AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error) {
return nil, fmt.Errorf("attach volume not yet implemented")
Expand Down
7 changes: 7 additions & 0 deletions lib/instances/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,13 @@ type ForkSnapshotRequest struct {
TargetHypervisor hypervisor.Type // Optional, allowed only for Stopped snapshots
}

// UpdateCredentialsRequest is the domain request for replacing instance credentials.
// This is a full replacement — the provided credentials map replaces the existing one entirely.
type UpdateCredentialsRequest struct {
Credentials map[string]CredentialPolicy // New credential policies (replaces existing)
Env map[string]string // Updated env map containing real secret values
}

// AttachVolumeRequest is the domain request for attaching a volume (used for API compatibility)
type AttachVolumeRequest struct {
MountPath string
Expand Down
Loading
Loading