feat: enable auth for local requests to Corrosion API (resolves #110)

This commit is contained in:
Pasha Sviderski
2026-05-26 10:07:12 +10:00
parent 26951773d1
commit 9424daff28
6 changed files with 138 additions and 27 deletions
+32 -10
View File
@@ -33,21 +33,22 @@ type APIClient struct {
newResubBackoff func() backoff.BackOff newResubBackoff func() backoff.BackOff
} }
// NewAPIClient creates a new Corrosion API client. The client retries on network errors using an exponential backoff // NewAPIClient creates a new Corrosion API client. The bearerToken is sent in the Authorization header of every
// policy with a maximum interval of 1 second and a maximum elapsed time of 10 seconds. // request to authenticate against Corrosion API.
// The client retries on network errors using an exponential backoff policy with a maximum interval of 1 second and
// a maximum elapsed time of 10 seconds.
// It automatically resubscribes to active subscriptions if an error occurs using an exponential backoff policy with a // It automatically resubscribes to active subscriptions if an error occurs using an exponential backoff policy with a
// maximum interval of 1 second and a maximum elapsed time of 60 seconds. // maximum interval of 1 second and a maximum elapsed time of 60 seconds.
// Use the WithHTTP2Client option to provide a custom HTTP client and the WithResubscribeBackoff option to change the // Use the WithHTTP2Client option to provide a custom HTTP client and the WithResubscribeBackoff option to change the
// backoff policy for resubscribing to a query. // backoff policy for resubscribing to a query.
func NewAPIClient(addr netip.AddrPort, opts ...APIClientOption) (*APIClient, error) { func NewAPIClient(addr netip.AddrPort, bearerToken string, opts ...APIClientOption) (*APIClient, error) {
baseURL, err := url.Parse(fmt.Sprintf("http://%s", addr)) baseURL, err := url.Parse(fmt.Sprintf("http://%s", addr))
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err) return nil, fmt.Errorf("invalid URL: %w", err)
} }
c := &APIClient{
baseURL: baseURL, transport := &AuthRoundTripper{
client: &http.Client{ Base: &RetryRoundTripper{
Transport: &RetryRoundTripper{
Base: &http2.Transport{ Base: &http2.Transport{
AllowHTTP: true, AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
@@ -65,7 +66,11 @@ func NewAPIClient(addr netip.AddrPort, opts ...APIClientOption) (*APIClient, err
) )
}, },
}, },
}, Token: bearerToken,
}
c := &APIClient{
baseURL: baseURL,
client: &http.Client{Transport: transport},
newResubBackoff: func() backoff.BackOff { newResubBackoff: func() backoff.BackOff {
return backoff.NewExponentialBackOff( return backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond), backoff.WithInitialInterval(100*time.Millisecond),
@@ -83,6 +88,8 @@ func NewAPIClient(addr netip.AddrPort, opts ...APIClientOption) (*APIClient, err
type APIClientOption func(*APIClient) type APIClientOption func(*APIClient)
// WithHTTP2Client replaces the client's HTTP transport. The provided client bypasses the built-in bearer-token
// injection, so the caller is responsible for setting the Authorization header.
func WithHTTP2Client(client *http.Client) APIClientOption { func WithHTTP2Client(client *http.Client) APIClientOption {
return func(c *APIClient) { return func(c *APIClient) {
c.client = client c.client = client
@@ -97,6 +104,22 @@ func WithResubscribeBackoff(newBackoff func() backoff.BackOff) APIClientOption {
} }
} }
// AuthRoundTripper sets the Authorization header on every outgoing request. An empty Token leaves the header untouched.
type AuthRoundTripper struct {
Base http.RoundTripper
Token string
}
func (rt *AuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.Token == "" {
return rt.Base.RoundTrip(req)
}
// RoundTripper contract: must not mutate the caller's request.
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+rt.Token)
return rt.Base.RoundTrip(req)
}
type RetryRoundTripper struct { type RetryRoundTripper struct {
Base http.RoundTripper Base http.RoundTripper
// NewBackoff creates a new backoff policy for each request. // NewBackoff creates a new backoff policy for each request.
@@ -107,8 +130,7 @@ func (rt *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error
roundTrip := func() (*http.Response, error) { roundTrip := func() (*http.Response, error) {
resp, err := rt.Base.RoundTrip(req) resp, err := rt.Base.RoundTrip(req)
if err != nil { if err != nil {
var opErr *net.OpError if _, ok := errors.AsType[*net.OpError](err); ok {
if errors.As(err, &opErr) {
// Not certain, but I expect operational errors should generally be retryable. // Not certain, but I expect operational errors should generally be retryable.
slog.Debug("Retrying corrosion API request due to network error.", "error", err) slog.Debug("Retrying corrosion API request due to network error.", "error", err)
return nil, err return nil, err
+60
View File
@@ -0,0 +1,60 @@
package corrosion
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuthRoundTripper_SetsAuthorizationHeader(t *testing.T) {
t.Parallel()
const token = "test-token-1234567890"
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
rt := &AuthRoundTripper{Base: http.DefaultTransport, Token: token}
client := &http.Client{Transport: rt}
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, "Bearer "+token, gotAuth)
// Caller's request must not be mutated by the RoundTripper.
assert.Empty(t, req.Header.Get("Authorization"))
}
func TestAuthRoundTripper_EmptyTokenSkipsHeader(t *testing.T) {
t.Parallel()
var headerSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, headerSeen = r.Header["Authorization"]
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
rt := &AuthRoundTripper{Base: http.DefaultTransport, Token: ""}
client := &http.Client{Transport: rt}
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.False(t, headerSeen, "Authorization header should not be sent when token is empty")
}
+5
View File
@@ -38,6 +38,11 @@ type GossipConfig struct {
type APIConfig struct { type APIConfig struct {
Addr netip.AddrPort `toml:"addr"` Addr netip.AddrPort `toml:"addr"`
Authz APIAuthzConfig `toml:"authz"`
}
type APIAuthzConfig struct {
BearerToken string `toml:"bearer-token"`
} }
type AdminConfig struct { type AdminConfig struct {
+1 -1
View File
@@ -35,7 +35,7 @@ func WaitReady(ctx context.Context, dataDir string) error {
return fmt.Errorf("unmarshal config: %w", err) return fmt.Errorf("unmarshal config: %w", err)
} }
corro, err := corrosion.NewAPIClient(config.API.Addr) corro, err := corrosion.NewAPIClient(config.API.Addr, config.API.Authz.BearerToken)
if err != nil { if err != nil {
return fmt.Errorf("create corrosion API client: %w", err) return fmt.Errorf("create corrosion API client: %w", err)
} }
+22 -1
View File
@@ -35,6 +35,7 @@ import (
machinedocker "github.com/psviderski/uncloud/internal/machine/docker" machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store" "github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/unregistry" "github.com/psviderski/unregistry"
"github.com/siderolabs/grpc-proxy/proxy" "github.com/siderolabs/grpc-proxy/proxy"
@@ -224,7 +225,21 @@ func NewMachine(config *Config) (*Machine, error) {
} }
} }
corro, err := corrosion.NewAPIClient(config.CorrosionAPIAddr) // Generate and persist a token for Corrosion API if not already present in the state.
if len(state.CorrosionAPIToken) == 0 {
token, tErr := secret.New(16)
if tErr != nil {
return nil, fmt.Errorf("generate corrosion API token: %w", tErr)
}
state.CorrosionAPIToken = token
if err = state.Save(); err != nil {
return nil, fmt.Errorf("save machine state with corrosion API token: %w", err)
}
slog.Info("Generated Corrosion API bearer token.")
}
corro, err := corrosion.NewAPIClient(config.CorrosionAPIAddr, state.CorrosionAPIToken.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("create corrosion API client: %w", err) return nil, fmt.Errorf("create corrosion API client: %w", err)
} }
@@ -603,6 +618,9 @@ func listenUnixSocket(path string) (net.Listener, error) {
} }
func (m *Machine) configureCorrosion() error { func (m *Machine) configureCorrosion() error {
if len(m.state.CorrosionAPIToken) == 0 {
return fmt.Errorf("corrosion API token not set in machine state")
}
if err := corroservice.MkDir(m.config.CorrosionDataDir, m.config.CorrosionUser); err != nil { if err := corroservice.MkDir(m.config.CorrosionDataDir, m.config.CorrosionUser); err != nil {
return fmt.Errorf("create corrosion data directory: %w", err) return fmt.Errorf("create corrosion data directory: %w", err)
} }
@@ -639,6 +657,9 @@ func (m *Machine) configureCorrosion() error {
}, },
API: corroservice.APIConfig{ API: corroservice.APIConfig{
Addr: m.config.CorrosionAPIAddr, Addr: m.config.CorrosionAPIAddr,
Authz: corroservice.APIAuthzConfig{
BearerToken: m.state.CorrosionAPIToken.String(),
},
}, },
Admin: corroservice.AdminConfig{ Admin: corroservice.AdminConfig{
Path: m.config.CorrosionAdminSockPath, Path: m.config.CorrosionAdminSockPath,
+3
View File
@@ -8,6 +8,7 @@ import (
"sync" "sync"
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/secret"
) )
const ( const (
@@ -28,6 +29,8 @@ type State struct {
// Per-actor vector (Corrosion actor UUID → max applied db_version) captured from an existing // Per-actor vector (Corrosion actor UUID → max applied db_version) captured from an existing
// member at join time. Cleared once reached. // member at join time. Cleared once reached.
MinStoreVersion map[string]int64 `json:",omitempty"` MinStoreVersion map[string]int64 `json:",omitempty"`
// CorrosionAPIToken authenticates requests to the local Corrosion API.
CorrosionAPIToken secret.Secret `json:",omitempty"`
// path is the file path config is read from and saved to. // path is the file path config is read from and saved to.
path string path string