Skip to content
Merged
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
6 changes: 3 additions & 3 deletions cmd/lk/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,9 @@ func requireProjectWithOpts(ctx context.Context, cmd *cli.Command, opts ...loadO
case errors.Is(err, config.ErrInvalidConfig):
// something is wrong with the project config file
return ctx, err
case err != nil && namedProject(cmd):
// the user named a project that doesn't resolve: say so rather than
// act on another one
case err != nil && (namedProject(cmd) || cmd.Bool("dev")):
// the user named a project, or asked for dev credentials, that doesn't
// resolve: say so rather than act on another project
return ctx, err
case err != nil:
// no project could be resolved automatically; choose from existing
Expand Down
10 changes: 6 additions & 4 deletions cmd/lk/credential_property_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,15 +533,17 @@ func requestProject(line string) (string, bool) {
}

// mustFail is set when lk can't act: both mode flags at once, a Public API
// command outside --user-auth, or, under --user-auth, no user who can act (no
// unexpired session, a --user with no unexpired session, or a Public API URL
// nobody is signed in to). Every drawn user is signed in to publicAPI.
// command outside --user-auth, --dev with an inline key or secret outside
// --user-auth, or, under --user-auth, no user who can act (no unexpired
// session, a --user with no unexpired session, or a Public API URL nobody is
// signed in to). Every drawn user is signed in to publicAPI.
func (c credentialCase) mustFail() bool {
_, key, secret := c.inlineCredentials()
switch {
case c.userAuth && c.legacyAuth:
return true
case !c.userAuth:
return c.command.publicAPI
return c.command.publicAPI || c.dev && (key != "" || secret != "")
case !slices.ContainsFunc(c.users, func(u sessionUser) bool { return !u.expired }):
return true
case c.user != "" && !slices.ContainsFunc(c.users, func(u sessionUser) bool { return (u.id == c.user || u.email == c.user) && !u.expired }):
Expand Down
37 changes: 37 additions & 0 deletions cmd/lk/testdata/script/dev-with-api-key.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# --dev with an API key or secret, from its flag or its env var, is an error
# naming what to remove. lk never falls back to the only project: the fake
# would answer that project's server, so lk can only fail by refusing.
respond POST http://one.test/twirp/livekit.RoomService/ListRooms 200 ''
respond POST http://dev.test/twirp/livekit.RoomService/ListRooms 200 ''

env LIVEKIT_API_KEY=APIenv
! exec lk --dev room list
stderr '^--dev can''t be combined with API key credentials; unset LIVEKIT_API_KEY or drop --dev$'

env LIVEKIT_API_SECRET=secretenv
! exec lk --dev room list
stderr '^--dev can''t be combined with API key credentials; unset LIVEKIT_API_KEY and LIVEKIT_API_SECRET or drop --dev$'

env LIVEKIT_API_KEY=
env LIVEKIT_API_SECRET=
! exec lk --dev --api-key APIflag room list
stderr '^--dev can''t be combined with API key credentials; remove --api-key or drop --dev$'

requests
! stdout .

# --dev alone still uses the dev credentials, at --url.
exec lk --dev --url ws://dev.test room list
stderr '^Using dev credentials$'
requests
cmp stdout want-requests

-- home/.livekit/cli-config.yaml --
projects:
- name: one
project_id: p_1
url: ws://one.test
api_key: APIone
api_secret: secretone
-- want-requests --
POST http://dev.test/twirp/livekit.RoomService/ListRooms apikey=devkey
39 changes: 32 additions & 7 deletions cmd/lk/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,8 @@ func (rp *resolvedProject) announce() {
//
// It performs no prompting and no printing: callers print rp.notice via out.Status.
// requireProjectWithOpts handles a returned error by offering interactive
// selection, unless --project or --subdomain named a project (see namedProject).
// selection, unless --project or --subdomain named a project (see namedProject)
// or --dev asked for dev credentials.
func resolveProject(c *cli.Command, p loadParams) (*resolvedProject, error) {
// 1. explicit project
if c.String("project") != "" {
Expand All @@ -421,20 +422,17 @@ func resolveProject(c *cli.Command, p loadParams) (*resolvedProject, error) {
}

// 3. inline credentials (flags or environment)
if c.Bool("dev") && (c.String("api-key") != "" || c.String("api-secret") != "") {
return nil, devCredentialsError(c)
}
pc := &config.ProjectConfig{}
if val := c.String("url"); val != "" {
pc.URL = val
}
if val := c.String("api-key"); val != "" {
if c.Bool("dev") {
return nil, errors.New("both api-key and dev flags are set")
}
pc.APIKey = val
}
if val := c.String("api-secret"); val != "" {
if c.Bool("dev") {
return nil, errors.New("both api-secret and dev flags are set")
}
pc.APISecret = val
}
if pc.APIKey != "" && pc.APISecret != "" && (pc.URL != "" || !p.requireURL) {
Expand Down Expand Up @@ -493,6 +491,33 @@ func resolveProject(c *cli.Command, p loadParams) (*resolvedProject, error) {
return &resolvedProject{project: pc, source: sourceInlineFlags}, nil
}

// devCredentialsError is the error for --dev with an API key or secret. It
// names each one to remove: the env var when the value is the env var's, the
// flag otherwise.
func devCredentialsError(c *cli.Command) error {
var envVars, flags []string
for _, cred := range []struct{ flag, env string }{
{"api-key", "LIVEKIT_API_KEY"},
{"api-secret", "LIVEKIT_API_SECRET"},
} {
switch val := c.String(cred.flag); {
case val == "":
case val == os.Getenv(cred.env):
envVars = append(envVars, cred.env)
default:
flags = append(flags, "--"+cred.flag)
}
}
var fixes []string
if len(envVars) > 0 {
fixes = append(fixes, "unset "+strings.Join(envVars, " and "))
}
if len(flags) > 0 {
fixes = append(fixes, "remove "+strings.Join(flags, " and "))
}
return fmt.Errorf("--dev can't be combined with API key credentials; %s or drop --dev", strings.Join(fixes, " and "))
}

// loadProjectDetails resolves project credentials for commands that consume the result
// directly (egress, ingress, token, …) and announces the resolution. Commands that rely on
// the package-level `project` (app/agent) go through requireProject instead, which layers
Expand Down
79 changes: 79 additions & 0 deletions cmd/lk/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,33 @@ func TestResolveProjectSource(t *testing.T) {
assert.Contains(t, err.Error(), "both project and dev flags are set")
})

t.Run("dev and inline credentials conflict", func(t *testing.T) {
for _, tc := range []struct {
name string
key, secret string // LIVEKIT_API_KEY, LIVEKIT_API_SECRET
args []string
want string
}{
{name: "env key", key: "envkey",
want: "--dev can't be combined with API key credentials; unset LIVEKIT_API_KEY or drop --dev"},
{name: "env key and secret", key: "envkey", secret: "envsecret",
want: "--dev can't be combined with API key credentials; unset LIVEKIT_API_KEY and LIVEKIT_API_SECRET or drop --dev"},
{name: "flag key", args: []string{"--api-key", "k"},
want: "--dev can't be combined with API key credentials; remove --api-key or drop --dev"},
{name: "flag key over env key", key: "envkey", args: []string{"--api-key", "k"},
want: "--dev can't be combined with API key credentials; remove --api-key or drop --dev"},
{name: "flag key and env secret", secret: "envsecret", args: []string{"--api-key", "k"},
want: "--dev can't be combined with API key credentials; unset LIVEKIT_API_SECRET and remove --api-key or drop --dev"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("LIVEKIT_API_KEY", tc.key)
t.Setenv("LIVEKIT_API_SECRET", tc.secret)
_, err := resolveWith(t, append([]string{"--dev"}, tc.args...)...)
assert.EqualError(t, err, tc.want)
})
}
})

t.Run("--quiet suppresses the breadcrumb", func(t *testing.T) {
prev := out
var buf bytes.Buffer
Expand Down Expand Up @@ -204,6 +231,58 @@ func TestRequireProjectNamedProjectIsAnError(t *testing.T) {
}
}

// TestRequireProjectDevWithCredentialsIsAnError checks that --dev with an API
// key or secret, from its flag or its env var, fails instead of falling back
// to the only configured project, even when prompts are skipped.
func TestRequireProjectDevWithCredentialsIsAnError(t *testing.T) {
t.Setenv("LIVEKIT_URL", "")
t.Setenv("LIVEKIT_API_KEY", "")
t.Setenv("LIVEKIT_API_SECRET", "")
setHome(t, t.TempDir())
conf := &config.CLIConfig{Projects: []config.ProjectConfig{
{Name: "one", URL: "https://one.livekit.cloud", APIKey: "APIone", APISecret: "secretone"},
}}
require.NoError(t, conf.PersistQuietly())
prevProject, prevConfig := project, cliConfig
t.Cleanup(func() { project, cliConfig = prevProject, prevConfig })

for _, tc := range []struct {
name string
env string // LIVEKIT_API_KEY
args []string
}{
{name: "env key", env: "APIenv"},
{name: "flag key", args: []string{"--api-key", "APIflag"}},
{name: "flag secret", args: []string{"--api-secret", "secretflag"}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("LIVEKIT_API_KEY", tc.env)
project = nil
var err error
app := &cli.Command{
Name: "lk",
Flags: []cli.Flag{
&cli.StringFlag{Name: "url", Sources: cli.EnvVars("LIVEKIT_URL"), Value: "http://localhost:7880"},
&cli.StringFlag{Name: "api-key", Sources: cli.EnvVars("LIVEKIT_API_KEY")},
&cli.StringFlag{Name: "api-secret", Sources: cli.EnvVars("LIVEKIT_API_SECRET")},
&cli.BoolFlag{Name: "dev"},
&cli.StringFlag{Name: "project"},
&cli.StringFlag{Name: "subdomain"},
&cli.BoolFlag{Name: "user-auth"},
&cli.BoolFlag{Name: "yes"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
_, err = requireProjectWithOpts(ctx, cmd)
return nil
},
}
require.NoError(t, app.Run(context.Background(), append([]string{"lk", "--yes", "--dev"}, tc.args...)))
assert.ErrorContains(t, err, "--dev can't be combined with API key credentials")
assert.Nil(t, project, "no project is chosen when --dev conflicts with API key credentials")
})
}
}

func TestOptionalFlag(t *testing.T) {
requiredFlag := &cli.StringFlag{
Name: "test",
Expand Down
Loading