From a3c573067c63afe5a63b7da1b2ff9d619ab7d925 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Wed, 16 Sep 2026 04:27:26 +0000 Subject: [PATCH] Fall back to local ca.json edit when --ca-url is unset When `step ca provisioner add|update|remove` is given an existing `--ca-config` file but no `--ca-url`, edit the config locally instead of failing with a required `--ca-url`/`--root` error. Restores the documented offline bootstrap workflow broken since v0.23.1. Fixes #1705 --- command/ca/provisioner/provisioner.go | 36 ++++++++++----- command/ca/provisioner/provisioner_test.go | 52 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/command/ca/provisioner/provisioner.go b/command/ca/provisioner/provisioner.go index c0e9bcff..3e70bf66 100644 --- a/command/ca/provisioner/provisioner.go +++ b/command/ca/provisioner/provisioner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "os" "time" "github.com/pkg/errors" @@ -105,6 +106,15 @@ type crudClient interface { func newCRUDClient(cliCtx *cli.Context, cfgFile string) (crudClient, error) { unauthAdminClient, err := cautils.NewUnauthenticatedAdminClient(cliCtx) if err != nil { + // Documented offline workflow: `step ca provisioner add ... --ca-config + // ca.json` edits the file locally and must not require --ca-url/--root + // when those flags are absent. Fall back only when the config file + // exists; otherwise keep the original admin-client error. + if cfgFile != "" { + if _, statErr := os.Stat(cfgFile); statErr == nil { + return newLocalCaConfigClient(cfgFile) + } + } return nil, fmt.Errorf("error generating admin client: %w", err) } @@ -113,17 +123,7 @@ func newCRUDClient(cliCtx *cli.Context, cfgFile string) (crudClient, error) { err = unauthAdminClient.IsEnabled() switch { case errors.As(err, &netErr) || errors.Is(err, ca.ErrAdminAPINotImplemented): - ui.PrintSelected("CA Configuration", cfgFile) - cfg, err := config.LoadConfiguration(cfgFile) - if err != nil { - return nil, fmt.Errorf("error loading configuration: %w", err) - } - // Assume the ca.json is already valid to avoid enabling all the - // features present in step-ca just to modify the provisioners. - cfg.SkipValidation = true - - ui.Println() - return newCaConfigClient(context.Background(), cfg, cfgFile) + return newLocalCaConfigClient(cfgFile) case errors.Is(err, ca.ErrAdminAPINotAuthorized): return cautils.NewAdminClient(cliCtx) default: @@ -131,6 +131,20 @@ func newCRUDClient(cliCtx *cli.Context, cfgFile string) (crudClient, error) { } } +func newLocalCaConfigClient(cfgFile string) (crudClient, error) { + ui.PrintSelected("CA Configuration", cfgFile) + cfg, err := config.LoadConfiguration(cfgFile) + if err != nil { + return nil, fmt.Errorf("error loading configuration: %w", err) + } + // Assume the ca.json is already valid to avoid enabling all the + // features present in step-ca just to modify the provisioners. + cfg.SkipValidation = true + + ui.Println() + return newCaConfigClient(context.Background(), cfg, cfgFile) +} + func parseInstanceAge(ctx *cli.Context) (age string, err error) { if !ctx.IsSet("instance-age") { return diff --git a/command/ca/provisioner/provisioner_test.go b/command/ca/provisioner/provisioner_test.go index fe0168bf..33230729 100644 --- a/command/ca/provisioner/provisioner_test.go +++ b/command/ca/provisioner/provisioner_test.go @@ -3,14 +3,18 @@ package provisioner import ( "crypto/ed25519" "crypto/rand" + "flag" + "fmt" "net/netip" "os" + "path/filepath" "testing" "time" nebula "github.com/slackhq/nebula/cert" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/urfave/cli" ) func TestReadNebulaRoots(t *testing.T) { @@ -95,3 +99,51 @@ func serializeAndWriteNebulaCert(t *testing.T, tempDir string, cert nebula.Certi return file.Name(), data } + +func TestNewCRUDClient_CaConfigWithoutCaURL(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cfgFile := filepath.Join(dir, "ca.json") + // Paths need not exist: SkipValidation is set before authority.New. + cfg := fmt.Sprintf(`{ + "root": %q, + "crt": %q, + "key": %q, + "address": ":9000", + "dnsNames": ["localhost"], + "authority": { + "provisioners": [] + } +} +`, filepath.Join(dir, "root.crt"), filepath.Join(dir, "intermediate.crt"), filepath.Join(dir, "intermediate.key")) + require.NoError(t, os.WriteFile(cfgFile, []byte(cfg), 0o600)) + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + _ = set.String("ca-url", "", "") + _ = set.String("root", "", "") + _ = set.String("ca-config", cfgFile, "") + ctx := cli.NewContext(app, set, nil) + + client, err := newCRUDClient(ctx, cfgFile) + require.NoError(t, err) + require.NotNil(t, client) + _, ok := client.(*caConfigClient) + require.True(t, ok, "expected local caConfigClient when --ca-config exists and --ca-url is unset") +} + +func TestNewCRUDClient_MissingCaURLWithoutConfig(t *testing.T) { + t.Parallel() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + _ = set.String("ca-url", "", "") + _ = set.String("root", "", "") + ctx := cli.NewContext(app, set, nil) + + client, err := newCRUDClient(ctx, "") + require.Error(t, err) + require.Nil(t, client) + require.Contains(t, err.Error(), "ca-url") +}