diff --git a/README.md b/README.md index f2880d2..d74e33a 100644 --- a/README.md +++ b/README.md @@ -188,15 +188,23 @@ oidc: { ## File Discovery -`authk` employs a smart discovery mechanism for both the configuration file (`authk.cue`) and the `.env` file. It searches in the following order: +The configuration file (`authk.cue`) is searched in the following order: -1. **Explicit Path**: If provided via flags (`--config`, `--env`). +1. **Explicit Path**: If provided via `--config`. 2. **Current Directory**: Checks the current working directory. 3. **Parent Directories**: Walks up the directory tree to the root. 4. **Home Directory**: Checks the user's home directory (`$HOME`). This allows you to run `authk` from any subdirectory within your project or rely on a global configuration in your home directory. +When the config has no `targets`, the `.env` file is found as follows: + +- **Explicit path** (`--env front/.env`): used as is, relative to the current directory. +- **Project config** (not in the home directory root): searched from the current directory up to the config directory, never above. If none is found, it is created next to the config. +- **Global config** (`~/authk.cue`): searched from the current directory up to, but excluding, the home directory. If none is found, it is created in the current directory. + +`~/.env` is never used implicitly: a project without `.env` must not write its token there, nor share it with every other project. + ## Usage ### Agent (Long-running) @@ -204,24 +212,43 @@ This allows you to run `authk` from any subdirectory within your project or rely This is the main mode. The agent fetches a token, updates the `.env` file (or the configured targets), and keeps running to renew the token before it expires. It stops cleanly on `Ctrl+C` or `SIGTERM`. ```bash -./authk agent --env .env +./authk agent # in the foreground +./authk agent --detach # in the background (-d) ``` While the agent runs, `authk get` and `authk inspect` use its current token through a local socket instead of authenticating again. The agent is the only process that renews the token, which matters with refresh token rotation (Keycloak *Revoke Refresh Token*). -One agent runs per config file. Its socket lives in `$XDG_RUNTIME_DIR/authk/` (or a private directory in the system temp dir); set `AUTHK_SOCK` to use a specific path. +An agent serves one config file, and one `.env` file when the config has no `targets` (`--env` is ignored, with a warning, when the config defines `targets`). Several agents can run at the same time; a second agent for the same files is refused. ```bash -./authk agent status # show the agent state (--json available) -./authk agent refresh # renew the token now -./authk agent stop # stop the agent +./authk agent list # list running agents (alias: ls, --json available) +./authk agent status [ID] # show an agent state (--json available) +./authk agent refresh [ID] # renew the token now +./authk agent logs [ID] -f # follow the logs of a detached agent +./authk agent stop [ID] # stop an agent +./authk agent stop --all # stop every agent +``` + +Without ID, these commands act on the agent serving the current directory. An ID prefix is enough, as with Docker. + +``` +$ authk agent list +ID PID STARTED TOKEN MODE CONFIG WRITES +281028a4-b3def358 31337 2026-11-06 09:12 4m12s detached ~/dev/app/authk.cue ~/dev/app/front/.env +281028a4-05a23f95 31420 2026-11-06 09:13 4m30s detached ~/dev/app/authk.cue ~/dev/app/back/.env +d4a4eba5 31502 2026-11-06 09:30 1m58s foreground ~/dev/other/authk.cue ~/dev/other/out/.env ``` -> Running `authk` without a command still starts the agent, but is deprecated. +`--detach` returns once the agent has its first token, or prints its log if it fails to start. Logs of detached agents are written to `$XDG_STATE_HOME/authk/logs` (default `~/.local/state/authk/logs`) and kept 7 days. + +Sockets and registrations live in `$XDG_RUNTIME_DIR/authk/` (or a private directory in the system temp dir); set `AUTHK_SOCK` to use a specific socket path. + +> Running `authk` without a command still starts the agent in the foreground, but is deprecated. **Flags:** - `--config`: Path to config file (default: `authk.cue`) -- `--env`: Path to .env file (default: `.env`) +- `--env`: Path to .env file (default: `.env`), when the config has no `targets` +- `--detach`, `-d`: Run in the background - `--debug`: Enable debug logging ### Get Token (One-off) @@ -233,7 +260,9 @@ Prints a valid token to stdout. Useful for piping to other commands. curl -H "Authorization: Bearer $(./authk get)" https://api.example.com ``` -If an agent runs, its current token is printed (renewed first if it expires within `--min-ttl`). Otherwise a new token is requested from the provider. If the agent runs but fails, `get` returns its error instead of authenticating on its own. +If an agent serves the config, its current token is printed (renewed first if it expires within `--min-ttl`). Otherwise a new token is requested from the provider. If the agent runs but fails, `get` returns its error instead of authenticating on its own. + +When several agents serve the config (one per `.env` file), `get` uses the one writing the `.env` file found from the current directory, or given with `--env`; if none matches, it fails and lists them. Set `AUTHK_SOCK` to target a specific agent. **Flags:** - `--id-token`: Print ID Token instead of Access Token diff --git a/cmd/authk/agent.go b/cmd/authk/agent.go index 948c6dc..8ff59d5 100644 --- a/cmd/authk/agent.go +++ b/cmd/authk/agent.go @@ -8,6 +8,7 @@ import ( "io" "os" "os/signal" + "slices" "strings" "syscall" "time" @@ -23,28 +24,42 @@ import ( "golang.org/x/sync/errgroup" ) -var statusJSON bool +var ( + statusJSON bool + stopAll bool + agentDetach bool +) var agentCmd = &cobra.Command{ Use: "agent", Short: "Keep the token valid and serve it to other commands", - Long: `Run in the foreground: fetch a token, write it to the configured targets and renew it -before it expires. While it runs, "authk get" and "authk inspect" use its current token -through a local socket instead of authenticating again. + Long: `Fetch a token, write it to the configured targets and renew it before it expires. +While it runs, "authk get" and "authk inspect" use its current token through a local +socket instead of authenticating again. + +An agent serves one config file (and one .env file when the config has no targets). +Several agents can run at the same time: "authk agent list" shows them. Set AUTHK_SOCK +to use a specific socket path. -One agent runs per config file. Set AUTHK_SOCK to use a specific socket path.`, +With --detach, the agent runs in the background and logs to a file +("authk agent logs").`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return runAgent(cmd.Context()) + if agentDetach && !isDetachedChild() { + return detachAgent(cmd) + } + return runAgent(cmd.Context(), cmd.Flags().Changed("env")) }, } var agentStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show the status of the agent", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - client, err := agentClient() + Use: "status [ID]", + Short: "Show the status of an agent", + Long: `Show the status of the agent with the given ID (a prefix is enough), or of the agent +serving the current directory without ID.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := selectAgent(cmd.Context(), args) if err != nil { return err } @@ -57,11 +72,11 @@ var agentStatusCmd = &cobra.Command{ } var agentRefreshCmd = &cobra.Command{ - Use: "refresh", - Short: "Make the agent renew its token now", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - client, err := agentClient() + Use: "refresh [ID]", + Short: "Make an agent renew its token now", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := selectAgent(cmd.Context(), args) if err != nil { return err } @@ -74,24 +89,62 @@ var agentRefreshCmd = &cobra.Command{ } var agentStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the agent", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - client, err := agentClient() + Use: "stop [ID]", + Short: "Stop an agent", + Long: `Stop the agent with the given ID (a prefix is enough), the agent serving the current +directory without ID, or every agent with --all.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if stopAll { + if len(args) > 0 { + return errors.New("--all does not take an ID") + } + return stopAllAgents(cmd) + } + client, err := selectAgent(ctx, args) if err != nil { return err } - if err := client.Stop(cmd.Context()); err != nil { + st, err := client.Status(ctx) + if err != nil { return err } - _, err = fmt.Fprintln(cmd.OutOrStdout(), "Agent stopped") + if err := client.Stop(ctx); err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Agent %s stopped\n", st.ID) return err }, } -func runAgent(parent context.Context) error { - printBanner() +func stopAllAgents(cmd *cobra.Command) error { + agents, err := agent.List(cmd.Context()) + if err != nil { + return err + } + if len(agents) == 0 { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "No agent running") + return err + } + var errs []error + for _, a := range agents { + if err := agent.NewClient(a.Socket).Stop(cmd.Context()); err != nil { + errs = append(errs, fmt.Errorf("agent %s: %w", a.ID, err)) + continue + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Agent %s stopped\n", a.ID); err != nil { + return err + } + } + return errors.Join(errs...) +} + +// runAgent runs the agent in the foreground. envChanged tells whether --env was given. +func runAgent(parent context.Context, envChanged bool) error { + if !isDetachedChild() { + printBanner() + } setupLogger(zerolog.InfoLevel) cfg, err := loadConfig() @@ -99,14 +152,27 @@ func runAgent(parent context.Context) error { return err } resolveEnvFile() + if envChanged && len(cfg.Targets) > 0 { + log.Warn().Msg("--env is ignored: the config defines targets") + } - socket, err := agent.SocketPath(cfgFile) + // The agent identity: the config, and the .env file only when it is the target + configPath, err := agent.CanonicalPath(cfgFile) if err != nil { return err } - // Fail fast, before authenticating, if an agent already serves this config + envKey := "" + if len(cfg.Targets) == 0 { + if envKey, err = agent.CanonicalPath(envFile); err != nil { + return err + } + } + id := agent.ID(configPath, envKey, os.Getenv(agent.SocketEnv)) + socket := agent.SocketFor(id) + + // Fail fast, before authenticating, if an agent already serves the same files if _, err := agent.NewClient(socket).Status(parent); err == nil { - return fmt.Errorf("%w (socket %s)", agent.ErrAlreadyRunning, socket) + return fmt.Errorf("%w (agent %s)", agent.ErrAlreadyRunning, id) } targets := buildTargets(cfg) @@ -126,19 +192,47 @@ func runAgent(parent context.Context) error { } updateTargets(targets, token) + ln, err := agent.Listen(socket) + if err != nil { + return err + } + entry := agent.Entry{ + ID: id, + PID: os.Getpid(), + Socket: socket, + ConfigPath: configPath, + EnvFile: envKey, + LogFile: os.Getenv(logFileEnv), + StartedAt: time.Now(), + } + if err := agent.Register(entry); err != nil { + _ = ln.Close() + return err + } + defer func() { + if err := agent.Unregister(id); err != nil { + log.Warn().Err(err).Msg("Failed to unregister agent") + } + }() + refresher := agent.NewRefresher(client, token, func(t *oauth2.Token) { updateTargets(targets, t) }) info := agent.Info{ - ConfigPath: cfgFile, + ID: id, + LogFile: entry.LogFile, + ConfigPath: configPath, Issuer: cfg.OIDC.IssuerURL, ClientID: cfg.OIDC.ClientID, } for _, t := range targets { info.Targets = append(info.Targets, fmt.Sprintf("%s (%s → %s)", t.File, t.Type, t.Key)) + if !slices.Contains(info.TargetFiles, t.File) { + info.TargetFiles = append(info.TargetFiles, t.File) + } } g, gctx := errgroup.WithContext(ctx) g.Go(func() error { return refresher.Run(gctx) }) - g.Go(func() error { return agent.Serve(gctx, socket, refresher, info, stop) }) + g.Go(func() error { return agent.Serve(gctx, ln, refresher, info, stop) }) if err := g.Wait(); err != nil { return err } @@ -198,7 +292,7 @@ func printStatus(cmd *cobra.Command, st *agent.Status) error { line := func(label, format string, args ...any) { fmt.Fprintf(&b, "%-14s"+format+"\n", append([]any{label + ":"}, args...)...) } - line("Agent", "running (pid %d, since %s)", st.PID, st.StartedAt.Format(time.DateTime)) + line("Agent", "%s, running (pid %d, since %s)", st.ID, st.PID, st.StartedAt.Format(time.DateTime)) line("Config", "%s", st.ConfigPath) line("Issuer", "%s", st.Issuer) line("Client", "%s", st.ClientID) @@ -212,6 +306,9 @@ func printStatus(cmd *cobra.Command, st *agent.Status) error { for _, t := range st.Targets { line("Target", "%s", t) } + if st.LogFile != "" { + line("Log file", "%s", st.LogFile) + } _, err := io.WriteString(out, b.String()) return err } @@ -237,6 +334,8 @@ func init() { for _, c := range []*cobra.Command{agentStatusCmd, agentRefreshCmd} { c.Flags().BoolVar(&statusJSON, "json", false, "Output as JSON") } + agentStopCmd.Flags().BoolVar(&stopAll, "all", false, "Stop every running agent") + agentCmd.Flags().BoolVarP(&agentDetach, "detach", "d", false, "Run the agent in the background") agentCmd.AddCommand(agentStatusCmd, agentRefreshCmd, agentStopCmd) rootCmd.AddCommand(agentCmd) } diff --git a/cmd/authk/agent_list.go b/cmd/authk/agent_list.go new file mode 100644 index 0000000..01f4089 --- /dev/null +++ b/cmd/authk/agent_list.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "text/tabwriter" + "time" + + "github.com/codozor/authk/internal/agent" + "github.com/spf13/cobra" +) + +var ( + listJSON bool + logsFollow bool +) + +var agentListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List running agents", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + agents, err := agent.List(cmd.Context()) + if err != nil { + return err + } + if listJSON { + return printAgentsJSON(cmd.OutOrStdout(), agents) + } + return printAgentsTable(cmd.OutOrStdout(), agents) + }, +} + +var agentLogsCmd = &cobra.Command{ + Use: "logs [ID]", + Short: "Show the logs of a detached agent", + Long: `Show the log file of the agent with the given ID (a prefix is enough), or of the agent +serving the current directory without ID. Only agents started with --detach have one.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := selectAgent(cmd.Context(), args) + if err != nil { + return err + } + st, err := client.Status(cmd.Context()) + if err != nil { + return err + } + if st.LogFile == "" { + return fmt.Errorf("agent %s was not started with --detach: it logs to its terminal", st.ID) + } + if !logsFollow { + f, err := os.Open(st.LogFile) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + _, err = io.Copy(cmd.OutOrStdout(), f) + return err + } + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + return followFile(ctx, cmd.OutOrStdout(), st.LogFile) + }, +} + +// listedAgent is the JSON form of a running agent. +type listedAgent struct { + agent.Entry + Status *agent.Status `json:"status,omitempty"` + Error string `json:"error,omitempty"` +} + +func printAgentsJSON(out io.Writer, agents []agent.Agent) error { + list := make([]listedAgent, 0, len(agents)) + for _, a := range agents { + la := listedAgent{Entry: a.Entry, Status: a.Status} + if a.Err != nil { + la.Error = a.Err.Error() + } + list = append(list, la) + } + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(list) +} + +func printAgentsTable(out io.Writer, agents []agent.Agent) error { + if len(agents) == 0 { + _, err := fmt.Fprintln(out, "No agent running") + return err + } + home, _ := os.UserHomeDir() + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tPID\tSTARTED\tTOKEN\tMODE\tCONFIG\tWRITES") + for _, a := range agents { + mode := "foreground" + if a.LogFile != "" { + mode = "detached" + } + token, writes := "not responding", "?" + if a.Status != nil { + token = shortExpiry(a.Status.Expiry) + if a.Status.LastError != "" { + token += " (error)" + } + files := make([]string, 0, len(a.Status.TargetFiles)) + for _, f := range a.Status.TargetFiles { + files = append(files, tildePath(f, home)) + } + writes = strings.Join(files, ", ") + } + _, _ = fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%s\t%s\n", + a.ID, a.PID, a.StartedAt.Local().Format("2006-01-02 15:04"), token, mode, + tildePath(a.ConfigPath, home), writes) + } + return w.Flush() +} + +// shortExpiry returns "4m12s", "EXPIRED" or "no expiry". +func shortExpiry(expiry time.Time) string { + if expiry.IsZero() { + return "no expiry" + } + remaining := time.Until(expiry).Round(time.Second) + if remaining <= 0 { + return "EXPIRED" + } + return remaining.String() +} + +// tildePath shortens a path under the home directory to ~/... +func tildePath(path, home string) string { + if home == "" { + return path + } + if rel, err := filepath.Rel(home, path); err == nil && !strings.HasPrefix(rel, "..") { + return filepath.Join("~", rel) + } + return path +} + +// followFile prints path, then what is appended to it, until ctx is cancelled. +func followFile(ctx context.Context, out io.Writer, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := io.Copy(out, f); err != nil { + return err + } + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + return ctx.Err() + case <-ticker.C: + } + } +} + +func init() { + agentListCmd.Flags().BoolVar(&listJSON, "json", false, "Output as JSON") + agentLogsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "Keep printing new log lines") + agentCmd.AddCommand(agentListCmd, agentLogsCmd) +} diff --git a/cmd/authk/agent_list_test.go b/cmd/authk/agent_list_test.go new file mode 100644 index 0000000..94d2cd3 --- /dev/null +++ b/cmd/authk/agent_list_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "path/filepath" + "slices" + "testing" + "time" +) + +func TestWithoutDetachFlag(t *testing.T) { + got := withoutDetachFlag([]string{"agent", "--detach", "--config", "x.cue", "-d", "--detach=true", "--debug"}) + want := []string{"agent", "--config", "x.cue", "--debug"} + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestTildePath(t *testing.T) { + home := filepath.FromSlash("/home/u") + if got := tildePath(filepath.FromSlash("/home/u/dev/authk.cue"), home); got != filepath.FromSlash("~/dev/authk.cue") { + t.Errorf("got %s", got) + } + if got := tildePath(filepath.FromSlash("/etc/authk.cue"), home); got != filepath.FromSlash("/etc/authk.cue") { + t.Errorf("got %s", got) + } +} + +func TestShortExpiry(t *testing.T) { + if got := shortExpiry(time.Time{}); got != "no expiry" { + t.Errorf("got %s", got) + } + if got := shortExpiry(time.Now().Add(-time.Second)); got != "EXPIRED" { + t.Errorf("got %s", got) + } + if got := shortExpiry(time.Now().Add(90*time.Second + 400*time.Millisecond)); got != "1m30s" { + t.Errorf("got %s", got) + } +} diff --git a/cmd/authk/common.go b/cmd/authk/common.go index f33c26f..532bee7 100644 --- a/cmd/authk/common.go +++ b/cmd/authk/common.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" @@ -9,14 +10,17 @@ import ( "github.com/codozor/authk/internal/env" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "golang.org/x/term" ) -// setupLogger configures the global logger on stderr. --debug overrides the given level. +// setupLogger configures the global logger on stderr, without colors when stderr is +// not a terminal (detached agent writing to a log file). --debug overrides the level. func setupLogger(level zerolog.Level) { if debug { level = zerolog.DebugLevel } - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).Level(level) + noColor := !term.IsTerminal(int(os.Stderr.Fd())) + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: noColor}).Level(level) } // findConfig looks for the config file in the current directory, its parents and the @@ -38,20 +42,53 @@ func loadConfig() (*config.Config, error) { return cfg, nil } -// resolveEnvFile looks for the .env file in the current directory, its parents and the -// home directory. If it is not found, the flag value is kept as is. +// resolveEnvFile sets envFile to the absolute path of the .env file to use with the +// config file (see env.ResolveEnvFile). findConfig must have been called. func resolveEnvFile() { - if found, err := env.Find(envFile); err == nil { - envFile = found + cwd, err := os.Getwd() + if err != nil { + return + } + home, _ := os.UserHomeDir() + if resolved, err := env.ResolveEnvFile(envFile, cfgFile, cwd, home); err == nil { + envFile = resolved } } -// agentClient returns a client for the agent serving the config file. -func agentClient() (*agent.Client, error) { +// agentForContext returns a client for the agent serving the config and .env files +// found from the current directory, or for AUTHK_SOCK if set. It returns an error +// wrapping agent.ErrNotRunning if there is none. +func agentForContext(ctx context.Context) (*agent.Client, error) { + if socket := os.Getenv(agent.SocketEnv); socket != "" { + return agent.NewClient(socket), nil + } + findConfig() - socket, err := agent.SocketPath(cfgFile) + resolveEnvFile() + configPath, err := agent.CanonicalPath(cfgFile) + if err != nil { + return nil, err + } + envPath, err := agent.CanonicalPath(envFile) + if err != nil { + return nil, err + } + a, err := agent.Find(ctx, configPath, envPath) + if err != nil { + return nil, err + } + return agent.NewClient(a.Socket), nil +} + +// selectAgent returns a client for the agent whose ID starts with args[0], or for the +// agent of the current context without argument. +func selectAgent(ctx context.Context, args []string) (*agent.Client, error) { + if len(args) == 0 { + return agentForContext(ctx) + } + a, err := agent.FindByID(ctx, args[0]) if err != nil { return nil, err } - return agent.NewClient(socket), nil + return agent.NewClient(a.Socket), nil } diff --git a/cmd/authk/detach.go b/cmd/authk/detach.go new file mode 100644 index 0000000..a05217f --- /dev/null +++ b/cmd/authk/detach.go @@ -0,0 +1,162 @@ +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/codozor/authk/internal/agent" + "github.com/spf13/cobra" +) + +const ( + // detachedEnv marks the background process started by --detach. + detachedEnv = "AUTHK_DETACHED" + // logFileEnv gives the detached agent its log file, recorded in its registry entry. + logFileEnv = "AUTHK_LOG_FILE" + + // detachTimeout bounds the wait for the detached agent to be ready (first token). + detachTimeout = 2 * time.Minute + // logRetention is how long logs of stopped agents are kept. + logRetention = 7 * 24 * time.Hour +) + +func isDetachedChild() bool { + return os.Getenv(detachedEnv) == "1" +} + +// logDir returns the directory of detached agent logs: $XDG_STATE_HOME/authk/logs, +// ~/.local/state/authk/logs, or the user cache directory on other systems. +func logDir() (string, error) { + if dir := os.Getenv("XDG_STATE_HOME"); dir != "" { + return filepath.Join(dir, "authk", "logs"), nil + } + home, err := os.UserHomeDir() + if err == nil && runtime.GOOS != "windows" { + return filepath.Join(home, ".local", "state", "authk", "logs"), nil + } + cache, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(cache, "authk", "logs"), nil +} + +// detachAgent starts the agent in the background, waits until it serves its first +// token, and prints its ID. If it fails to start, its log is printed. +func detachAgent(cmd *cobra.Command) error { + dir, err := logDir() + if err != nil { + return fmt.Errorf("failed to find the log directory: %w", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create the log directory: %w", err) + } + pruneLogs(dir) + + logPath := filepath.Join(dir, "agent-"+time.Now().Format("20060102-150405.000")+".log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("failed to create the log file: %w", err) + } + + exe, err := os.Executable() + if err != nil { + _ = logFile.Close() + return fmt.Errorf("failed to find the authk executable: %w", err) + } + child := exec.Command(exe, withoutDetachFlag(os.Args[1:])...) + child.Stdout = logFile + child.Stderr = logFile + child.Env = append(os.Environ(), detachedEnv+"=1", logFileEnv+"="+logPath) + setDetachAttrs(child) + + if err := child.Start(); err != nil { + _ = logFile.Close() + return fmt.Errorf("failed to start the agent: %w", err) + } + _ = logFile.Close() // the child keeps its own descriptor + + exited := make(chan error, 1) + go func() { exited <- child.Wait() }() + + ctx := cmd.Context() + deadline := time.After(detachTimeout) + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case err := <-exited: + // The log only explains the failure: print it and drop it + printLogTail(cmd, logPath) + _ = os.Remove(logPath) + if err == nil { + err = errors.New("exited") + } + return fmt.Errorf("agent failed to start (%v)", err) + case <-deadline: + _ = child.Process.Kill() + printLogTail(cmd, logPath) + return fmt.Errorf("agent not ready after %s, log: %s", detachTimeout, logPath) + case <-ctx.Done(): + _ = child.Process.Kill() + return ctx.Err() + case <-ticker.C: + agents, err := agent.List(ctx) + if err != nil { + continue + } + for _, a := range agents { + if a.PID == child.Process.Pid && a.Status != nil { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Agent %s started in the background (pid %d)\nLogs: authk agent logs %s\n", + a.ID, a.PID, a.ID) + return err + } + } + } + } +} + +// withoutDetachFlag removes --detach / -d from the command line of the child. +func withoutDetachFlag(args []string) []string { + out := make([]string, 0, len(args)) + for _, a := range args { + switch { + case a == "--detach", a == "-d", strings.HasPrefix(a, "--detach="): + continue + } + out = append(out, a) + } + return out +} + +// printLogTail prints the end of a log file to stderr, to explain a failed start. +func printLogTail(cmd *cobra.Command, path string) { + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return + } + const maxTail = 4096 + if len(data) > maxTail { + data = data[len(data)-maxTail:] + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "--- agent log ---\n%s\n-----------------\n", strings.TrimRight(string(data), "\n")) +} + +// pruneLogs removes logs older than logRetention. +func pruneLogs(dir string) { + files, err := filepath.Glob(filepath.Join(dir, "agent-*.log")) + if err != nil { + return + } + for _, f := range files { + if info, err := os.Stat(f); err == nil && time.Since(info.ModTime()) > logRetention { + _ = os.Remove(f) + } + } +} diff --git a/cmd/authk/detach_unix.go b/cmd/authk/detach_unix.go new file mode 100644 index 0000000..c7ac080 --- /dev/null +++ b/cmd/authk/detach_unix.go @@ -0,0 +1,14 @@ +//go:build unix + +package main + +import ( + "os/exec" + "syscall" +) + +// setDetachAttrs starts the child in a new session: no controlling terminal, so closing +// the terminal (SIGHUP) or Ctrl+C in it does not stop the agent. +func setDetachAttrs(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/cmd/authk/detach_windows.go b/cmd/authk/detach_windows.go new file mode 100644 index 0000000..67588b5 --- /dev/null +++ b/cmd/authk/detach_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +// detachedProcess is DETACHED_PROCESS: the child has no console. +const detachedProcess = 0x00000008 + +// setDetachAttrs starts the child without console, in its own process group, so that +// closing the terminal or Ctrl+C in it does not stop the agent. +func setDetachAttrs(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess, + } +} diff --git a/cmd/authk/get.go b/cmd/authk/get.go index e27bb48..3c6495f 100644 --- a/cmd/authk/get.go +++ b/cmd/authk/get.go @@ -36,11 +36,14 @@ OIDC provider.`, } if !getNoAgent { - client, err := agentClient() - if err != nil { + client, err := agentForContext(cmd.Context()) + if err != nil && !isNotRunning(err) { return err } - resp, err := client.Token(cmd.Context(), typ, getMinTTL) + var resp *agent.TokenResponse + if err == nil { + resp, err = client.Token(cmd.Context(), typ, getMinTTL) + } if err == nil { log.Debug().Msg("Token served by the agent") _, err = fmt.Fprintln(cmd.OutOrStdout(), resp.Token) diff --git a/cmd/authk/inspect.go b/cmd/authk/inspect.go index 58f1a7c..45775a0 100644 --- a/cmd/authk/inspect.go +++ b/cmd/authk/inspect.go @@ -88,11 +88,14 @@ actually read. Use the --json flag for machine-readable output.`, // runs, the .env file otherwise or when asked to (--from-env, or --env given). func currentToken(cmd *cobra.Command, requestedType string) (token, source string, err error) { if !inspectFromEnv && !cmd.Flags().Changed("env") { - client, err := agentClient() - if err != nil { + client, err := agentForContext(cmd.Context()) + if err != nil && !isNotRunning(err) { return "", "", err } - resp, err := client.Token(cmd.Context(), requestedType, 0) + var resp *agent.TokenResponse + if err == nil { + resp, err = client.Token(cmd.Context(), requestedType, 0) + } if err == nil { return resp.Token, sourceAgent, nil } diff --git a/cmd/authk/root.go b/cmd/authk/root.go index 9016469..b9811a1 100644 --- a/cmd/authk/root.go +++ b/cmd/authk/root.go @@ -26,7 +26,7 @@ does the same but is deprecated.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { fmt.Fprintln(os.Stderr, `Warning: running authk without a command is deprecated, use "authk agent"`) - return runAgent(cmd.Context()) + return runAgent(cmd.Context(), cmd.Flags().Changed("env")) }, } diff --git a/go.mod b/go.mod index 41557bd..8ddb457 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/spf13/cobra v1.10.1 golang.org/x/oauth2 v0.33.0 golang.org/x/sync v0.18.0 + golang.org/x/term v0.37.0 ) require ( @@ -212,7 +213,6 @@ require ( golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.253.0 // indirect diff --git a/internal/agent/api.go b/internal/agent/api.go index 1071932..abcacd6 100644 --- a/internal/agent/api.go +++ b/internal/agent/api.go @@ -17,11 +17,14 @@ type TokenResponse struct { // Status is returned by GET /v1/status and POST /v1/refresh. type Status struct { + ID string `json:"id"` + LogFile string `json:"logFile,omitempty"` PID int `json:"pid"` ConfigPath string `json:"configPath"` Issuer string `json:"issuer"` ClientID string `json:"clientId"` Targets []string `json:"targets"` + TargetFiles []string `json:"targetFiles"` StartedAt time.Time `json:"startedAt"` Expiry time.Time `json:"expiry,omitzero"` HasRefreshToken bool `json:"hasRefreshToken"` diff --git a/internal/agent/registry.go b/internal/agent/registry.go new file mode 100644 index 0000000..a3fa240 --- /dev/null +++ b/internal/agent/registry.go @@ -0,0 +1,230 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// ErrAmbiguous is returned when several agents match and none can be chosen. +var ErrAmbiguous = errors.New("several agents match") + +// pingTimeout bounds the status request used to check that a registered agent is alive. +const pingTimeout = 3 * time.Second + +// Entry is the registration of a running agent, written in Dir() as .json. +// It makes every agent discoverable, even one using a custom socket (AUTHK_SOCK). +type Entry struct { + ID string `json:"id"` + PID int `json:"pid"` + Socket string `json:"socket"` + ConfigPath string `json:"configPath"` + EnvFile string `json:"envFile,omitempty"` // only when the config has no targets + LogFile string `json:"logFile,omitempty"` // only for a detached agent + StartedAt time.Time `json:"startedAt"` +} + +// Agent is a registered agent with its live status. Status is nil and Err set if the +// agent did not answer in time. +type Agent struct { + Entry + Status *Status + Err error +} + +func entryPath(id string) string { + return filepath.Join(Dir(), id+".json") +} + +// Register writes the registry entry of an agent. +func Register(e Entry) error { + if err := ensurePrivateDir(Dir()); err != nil { + return err + } + data, err := json.MarshalIndent(e, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(Dir(), "."+e.ID+".json-*") + if err != nil { + return fmt.Errorf("failed to register agent: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return fmt.Errorf("failed to register agent: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + if err := os.Rename(tmp.Name(), entryPath(e.ID)); err != nil { + _ = os.Remove(tmp.Name()) + return fmt.Errorf("failed to register agent: %w", err) + } + return nil +} + +// Unregister removes the registry entry of an agent. +func Unregister(id string) error { + if err := os.Remove(entryPath(id)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func readEntries() ([]Entry, error) { + files, err := filepath.Glob(filepath.Join(Dir(), "*.json")) + if err != nil { + return nil, err + } + var entries []Entry + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + continue // removed meanwhile + } + var e Entry + if json.Unmarshal(data, &e) != nil || e.ID == "" { + continue + } + entries = append(entries, e) + } + return entries, nil +} + +// List returns the registered agents sorted by start time, with their live status. +// Entries of agents that are gone (their socket refuses connections) are removed. +func List(ctx context.Context) ([]Agent, error) { + entries, err := readEntries() + if err != nil { + return nil, err + } + + agents := make([]Agent, len(entries)) + alive := make([]bool, len(entries)) + var wg sync.WaitGroup + for i, e := range entries { + wg.Add(1) + go func() { + defer wg.Done() + pingCtx, cancel := context.WithTimeout(ctx, pingTimeout) + defer cancel() + st, err := NewClient(e.Socket).Status(pingCtx) + if errors.Is(err, ErrNotRunning) { + removeStale(e) + return + } + agents[i] = Agent{Entry: e, Status: st, Err: err} + alive[i] = true + }() + } + wg.Wait() + + var result []Agent + for i, a := range agents { + if alive[i] { + result = append(result, a) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].StartedAt.Before(result[j].StartedAt) }) + return result, nil +} + +// removeStale removes what an agent that crashed left behind. +func removeStale(e Entry) { + _ = Unregister(e.ID) + if filepath.Dir(e.Socket) == Dir() { + _ = os.Remove(e.Socket) + } +} + +// Find returns the agent serving configPath for the .env file envFile (paths must be +// canonical): +// - the only running agent of this config, whatever its .env file: client and +// credentials come from the config, so its token is the right one; +// - among several, the one writing envFile (or using the config targets); +// - otherwise ErrAmbiguous, since their credentials may differ (ref+env:// secrets). +// +// It returns ErrNotRunning if no agent serves this config. +func Find(ctx context.Context, configPath, envFile string) (*Agent, error) { + agents, err := List(ctx) + if err != nil { + return nil, err + } + + var candidates []Agent + for _, a := range agents { + if a.ConfigPath == configPath { + candidates = append(candidates, a) + } + } + switch len(candidates) { + case 0: + return nil, ErrNotRunning + case 1: + return checkResponding(candidates[0]) + } + + var exact []Agent + for _, a := range candidates { + if envFile != "" && a.EnvFile == envFile { + exact = append(exact, a) + } + } + if len(exact) == 1 { + return checkResponding(exact[0]) + } + return nil, fmt.Errorf("%w for %s: %s; use --env, an agent ID or %s", + ErrAmbiguous, configPath, describe(candidates), SocketEnv) +} + +// FindByID returns the running agent whose ID starts with prefix. +func FindByID(ctx context.Context, prefix string) (*Agent, error) { + agents, err := List(ctx) + if err != nil { + return nil, err + } + var matches []Agent + for _, a := range agents { + if a.ID == prefix { + return checkResponding(a) + } + if strings.HasPrefix(a.ID, prefix) { + matches = append(matches, a) + } + } + switch len(matches) { + case 0: + return nil, fmt.Errorf("%w: no agent with ID %s", ErrNotRunning, prefix) + case 1: + return checkResponding(matches[0]) + } + return nil, fmt.Errorf("%w: ID prefix %s matches %s", ErrAmbiguous, prefix, describe(matches)) +} + +func checkResponding(a Agent) (*Agent, error) { + if a.Err != nil { + return nil, fmt.Errorf("agent %s (pid %d) is not responding: %w", a.ID, a.PID, a.Err) + } + return &a, nil +} + +func describe(agents []Agent) string { + parts := make([]string, 0, len(agents)) + for _, a := range agents { + target := a.EnvFile + if target == "" { + target = "config targets" + } + parts = append(parts, fmt.Sprintf("%s (%s)", a.ID, target)) + } + return strings.Join(parts, ", ") +} diff --git a/internal/agent/registry_test.go b/internal/agent/registry_test.go new file mode 100644 index 0000000..dbd1c78 --- /dev/null +++ b/internal/agent/registry_test.go @@ -0,0 +1,120 @@ +package agent + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// useRuntimeDir points Dir() to a new short directory for the test. +func useRuntimeDir(t *testing.T) { + t.Helper() + dir, err := os.MkdirTemp("", "akr") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + t.Setenv("XDG_RUNTIME_DIR", dir) + t.Setenv(SocketEnv, "") +} + +// runRegistered starts an agent registered like the real command does. +func runRegistered(t *testing.T, configPath, envFile string) Entry { + t.Helper() + id := ID(configPath, envFile, "") + socket := SocketFor(id) + ln, err := Listen(socket) + if err != nil { + t.Fatal(err) + } + e := Entry{ID: id, PID: os.Getpid(), Socket: socket, ConfigPath: configPath, EnvFile: envFile, StartedAt: time.Now()} + if err := Register(e); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + fc := &fakeClient{lifetime: time.Hour} + go func() { + done <- Serve(ctx, ln, NewRefresher(fc, fc.newToken(), nil), Info{ID: id, ConfigPath: configPath}, cancel) + }() + t.Cleanup(func() { + cancel() + <-done + _ = Unregister(id) + }) + return e +} + +func TestRegistry_ListAndFind(t *testing.T) { + useRuntimeDir(t) + ctx := context.Background() + + // One agent with targets on config A + a := runRegistered(t, "/p/a/authk.cue", "") + // Two agents on config B, one per .env file + front := runRegistered(t, "/p/b/authk.cue", "/p/b/front/.env") + runRegistered(t, "/p/b/authk.cue", "/p/b/back/.env") + + agents, err := List(ctx) + if err != nil { + t.Fatal(err) + } + if len(agents) != 3 { + t.Fatalf("expected 3 agents, got %d", len(agents)) + } + + // Single agent of the config: used whatever the .env file + got, err := Find(ctx, "/p/a/authk.cue", "/p/a/whatever/.env") + if err != nil || got.ID != a.ID { + t.Errorf("Find(A) = %v, %v; want %s", got, err, a.ID) + } + // Several agents: the one writing the .env file + got, err = Find(ctx, "/p/b/authk.cue", "/p/b/front/.env") + if err != nil || got.ID != front.ID { + t.Errorf("Find(B, front) = %v, %v; want %s", got, err, front.ID) + } + // Several agents, none writing this .env file: ambiguous + if _, err := Find(ctx, "/p/b/authk.cue", "/p/b/.env"); !errors.Is(err, ErrAmbiguous) { + t.Errorf("expected ErrAmbiguous, got %v", err) + } + // No agent for the config + if _, err := Find(ctx, "/p/c/authk.cue", "/p/c/.env"); !errors.Is(err, ErrNotRunning) { + t.Errorf("expected ErrNotRunning, got %v", err) + } + + // By ID prefix + got, err = FindByID(ctx, front.ID[:12]) + if err != nil || got.ID != front.ID { + t.Errorf("FindByID(prefix) = %v, %v; want %s", got, err, front.ID) + } + if _, err := FindByID(ctx, front.ID[:8]); !errors.Is(err, ErrAmbiguous) { + t.Errorf("a prefix shared by the 2 agents of config B must be ambiguous, got %v", err) + } + if _, err := FindByID(ctx, "ffffffff"); !errors.Is(err, ErrNotRunning) { + t.Errorf("expected ErrNotRunning, got %v", err) + } +} + +func TestRegistry_RemovesStaleEntries(t *testing.T) { + useRuntimeDir(t) + + // Entry of an agent that crashed: its socket does not exist anymore + stale := Entry{ID: "deadbeef", PID: 999999, Socket: filepath.Join(Dir(), "deadbeef.sock"), ConfigPath: "/p/x/authk.cue"} + if err := Register(stale); err != nil { + t.Fatal(err) + } + + agents, err := List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(agents) != 0 { + t.Errorf("stale agent listed: %+v", agents) + } + if _, err := os.Stat(entryPath("deadbeef")); !errors.Is(err, os.ErrNotExist) { + t.Error("stale entry was not removed") + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go index b7a942f..1992a0e 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "path/filepath" "runtime" "time" @@ -15,18 +16,22 @@ import ( "golang.org/x/oauth2" ) -// ErrAlreadyRunning is returned by Serve when another agent listens on the socket. -var ErrAlreadyRunning = errors.New("an agent is already running for this config") +// ErrAlreadyRunning is returned by Listen when another agent listens on the socket, +// i.e. already serves the same config (and .env file). +var ErrAlreadyRunning = errors.New("an agent is already running for this config and .env file") // renewTimeout bounds an on-demand renewal (the OIDC HTTP client times out after 30s). const renewTimeout = 35 * time.Second // Info describes what the agent serves, for status reporting. type Info struct { - ConfigPath string - Issuer string - ClientID string - Targets []string + ID string + LogFile string + ConfigPath string + Issuer string + ClientID string + Targets []string + TargetFiles []string } type server struct { @@ -37,14 +42,9 @@ type server struct { stop func() } -// Serve answers requests on socket until ctx is cancelled. stop is called when a client -// asks the agent to stop (POST /v1/stop); it should cancel ctx. -func Serve(ctx context.Context, socket string, r *Refresher, info Info, stop func()) error { - ln, err := listen(socket) - if err != nil { - return err - } - +// Serve answers requests on ln (see Listen) until ctx is cancelled. stop is called when +// a client asks the agent to stop (POST /v1/stop); it should cancel ctx. +func Serve(ctx context.Context, ln net.Listener, r *Refresher, info Info, stop func()) error { s := &server{ctx: ctx, refresher: r, info: info, startedAt: time.Now(), stop: stop} srv := &http.Server{Handler: s.routes(), ReadHeaderTimeout: 5 * time.Second} @@ -55,16 +55,17 @@ func Serve(ctx context.Context, socket string, r *Refresher, info Info, stop fun _ = srv.Shutdown(shutdownCtx) }() - log.Info().Str("socket", socket).Msg("Agent listening") + log.Info().Str("socket", ln.Addr().String()).Str("id", info.ID).Msg("Agent listening") if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("agent server failed: %w", err) } return nil } -// listen creates the socket, replacing a stale one left by an agent that crashed. -func listen(socket string) (net.Listener, error) { - if err := ensureSocketDir(socket); err != nil { +// Listen creates the agent socket, replacing a stale one left by an agent that crashed. +// It returns ErrAlreadyRunning if another agent listens on it. +func Listen(socket string) (net.Listener, error) { + if err := ensurePrivateDir(filepath.Dir(socket)); err != nil { return nil, err } if _, err := os.Stat(socket); err == nil { @@ -177,11 +178,14 @@ func (s *server) renew(seen *oauth2.Token) (*oauth2.Token, error) { func (s *server) status() Status { state := s.refresher.State() st := Status{ + ID: s.info.ID, + LogFile: s.info.LogFile, PID: os.Getpid(), ConfigPath: s.info.ConfigPath, Issuer: s.info.Issuer, ClientID: s.info.ClientID, Targets: s.info.Targets, + TargetFiles: s.info.TargetFiles, StartedAt: s.startedAt, LastRenewal: state.LastRenewal, } diff --git a/internal/agent/server_test.go b/internal/agent/server_test.go index 8e6ceae..838f8c8 100644 --- a/internal/agent/server_test.go +++ b/internal/agent/server_test.go @@ -29,6 +29,15 @@ func shortSocket(t *testing.T) string { return filepath.Join(dir, "a.sock") } +// serveOn listens on socket and serves r until ctx is cancelled. +func serveOn(ctx context.Context, socket string, r *Refresher, info Info, stop func()) error { + ln, err := Listen(socket) + if err != nil { + return err + } + return Serve(ctx, ln, r, info, stop) +} + // startAgent serves r on a new socket until the test ends. func startAgent(t *testing.T, r *Refresher) (*Client, *atomic.Bool) { t.Helper() @@ -37,7 +46,7 @@ func startAgent(t *testing.T, r *Refresher) (*Client, *atomic.Bool) { stopped := &atomic.Bool{} done := make(chan error, 1) go func() { - done <- Serve(ctx, socket, r, Info{ConfigPath: "/x/authk.cue", Issuer: "https://idp", ClientID: "cli"}, func() { + done <- serveOn(ctx, socket, r, Info{ConfigPath: "/x/authk.cue", Issuer: "https://idp", ClientID: "cli"}, func() { stopped.Store(true) cancel() }) @@ -173,7 +182,7 @@ func TestServer_AlreadyRunning(t *testing.T) { r := NewRefresher(fc, tokenWithID("a", "", time.Hour), nil) client, _ := startAgent(t, r) - err := Serve(context.Background(), client.socket, r, Info{}, func() {}) + err := serveOn(context.Background(), client.socket, r, Info{}, func() {}) if !errors.Is(err, ErrAlreadyRunning) { t.Errorf("expected ErrAlreadyRunning, got %v", err) } @@ -203,7 +212,7 @@ func TestClient_NotRunning(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { - done <- Serve(ctx, socket, NewRefresher(fc, tokenWithID("a", "", time.Hour), nil), Info{}, cancel) + done <- serveOn(ctx, socket, NewRefresher(fc, tokenWithID("a", "", time.Hour), nil), Info{}, cancel) }() deadline := time.Now().Add(2 * time.Second) for { @@ -221,25 +230,32 @@ func TestClient_NotRunning(t *testing.T) { } } -func TestSocketPath(t *testing.T) { +func TestIDAndSocket(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + t.Setenv(SocketEnv, "") - a1, err := SocketPath("/projects/a/authk.cue") - if err != nil { - t.Fatal(err) + a := ID("/projects/a/authk.cue", "", "") + if a != ID("/projects/a/authk.cue", "", "") { + t.Error("ID must be stable") } - a2, _ := SocketPath("/projects/a/authk.cue") - b, _ := SocketPath("/projects/b/authk.cue") - if a1 != a2 || a1 == b { - t.Errorf("socket must be stable per config and differ between configs: %s %s %s", a1, a2, b) + if a == ID("/projects/b/authk.cue", "", "") { + t.Error("configs must give different IDs") } - if filepath.Dir(a1) != filepath.Join("/run/user/1000", "authk") { - t.Errorf("unexpected socket dir %s", a1) + front := ID("/projects/a/authk.cue", "/projects/a/front/.env", "") + back := ID("/projects/a/authk.cue", "/projects/a/back/.env", "") + if front == back || !strings.HasPrefix(front, a+"-") { + t.Errorf("env files must give different IDs sharing the config prefix: %s %s %s", a, front, back) + } + if ID("/projects/a/authk.cue", "", "/custom.sock") == a { + t.Error("a custom socket must give a different ID") } + if got, want := SocketFor(a), filepath.Join("/run/user/1000", "authk", a+".sock"); got != want { + t.Errorf("SocketFor() = %s, want %s", got, want) + } t.Setenv(SocketEnv, "/custom.sock") - if p, _ := SocketPath("/projects/a/authk.cue"); p != "/custom.sock" { - t.Errorf("%s must override the path, got %s", SocketEnv, p) + if got := SocketFor(a); got != "/custom.sock" { + t.Errorf("%s must override the socket, got %s", SocketEnv, got) } } @@ -254,7 +270,7 @@ func TestEnsureSocketDir_RejectsSharedDir(t *testing.T) { if err := os.Chmod(dir, 0o755); err != nil { t.Fatal(err) } - if err := ensureSocketDir(filepath.Join(dir, "a.sock")); err == nil { + if err := ensurePrivateDir(dir); err == nil { t.Error("a directory readable by others must be rejected") } } diff --git a/internal/agent/socket.go b/internal/agent/socket.go index 801b2cd..041a7ba 100644 --- a/internal/agent/socket.go +++ b/internal/agent/socket.go @@ -13,43 +13,62 @@ import ( // SocketEnv overrides the socket path, like SSH_AUTH_SOCK for ssh-agent. const SocketEnv = "AUTHK_SOCK" -// SocketPath returns the socket of the agent serving configPath. -// One agent runs per config file: the name is derived from its absolute path. -func SocketPath(configPath string) (string, error) { - if p := os.Getenv(SocketEnv); p != "" { - return p, nil +// Dir returns the per-user directory holding agent sockets and registry entries. +// Unix socket paths are limited to about 100 bytes, so it must stay short. +func Dir() string { + if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { + return filepath.Join(dir, "authk") } + if runtime.GOOS == "windows" { + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "authk") + } + } + return filepath.Join(os.TempDir(), "authk-"+strconv.Itoa(os.Getuid())) +} - abs, err := filepath.Abs(configPath) +// CanonicalPath returns an absolute path with symlinks resolved when possible, so that +// the same file always gives the same agent ID. +func CanonicalPath(path string) (string, error) { + abs, err := filepath.Abs(path) if err != nil { - return "", fmt.Errorf("failed to resolve config path: %w", err) + return "", fmt.Errorf("failed to resolve path %s: %w", path, err) } if resolved, err := filepath.EvalSymlinks(abs); err == nil { - abs = resolved + return resolved, nil } - sum := sha256.Sum256([]byte(abs)) - return filepath.Join(socketDir(), hex.EncodeToString(sum[:])[:16]+".sock"), nil + return abs, nil } -// socketDir returns a per-user directory for sockets. Unix socket paths are limited to -// about 100 bytes, so it must stay short. -func socketDir() string { - if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { - return filepath.Join(dir, "authk") +// ID identifies an agent by what it serves: the config file, plus the .env file when +// the config has no targets (envFile is empty otherwise), plus a custom socket path +// (AUTHK_SOCK). Paths must be canonical. +func ID(configPath, envFile, customSocket string) string { + id := shortHash(configPath) + if envFile == "" && customSocket == "" { + return id } - if runtime.GOOS == "windows" { - if dir, err := os.UserCacheDir(); err == nil { - return filepath.Join(dir, "authk") - } + return id + "-" + shortHash(envFile+"\x00"+customSocket) +} + +// SocketFor returns the socket path of the agent id: AUTHK_SOCK if set, otherwise a +// socket in Dir(). +func SocketFor(id string) string { + if p := os.Getenv(SocketEnv); p != "" { + return p } - return filepath.Join(os.TempDir(), "authk-"+strconv.Itoa(os.Getuid())) + return filepath.Join(Dir(), id+".sock") +} + +func shortHash(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:])[:8] } -// ensureSocketDir creates the socket directory, private to the user. -func ensureSocketDir(socket string) error { - dir := filepath.Dir(socket) +// ensurePrivateDir creates dir, private to the user, and checks that nobody else can use it. +func ensurePrivateDir(dir string) error { if err := os.MkdirAll(dir, 0o700); err != nil { - return fmt.Errorf("failed to create socket directory: %w", err) + return fmt.Errorf("failed to create directory %s: %w", dir, err) } if runtime.GOOS == "windows" { return nil @@ -60,10 +79,10 @@ func ensureSocketDir(socket string) error { } // In a shared directory like /tmp, another user could have created it first if perm := info.Mode().Perm(); perm&0o077 != 0 { - return fmt.Errorf("socket directory %s is accessible by other users (mode %o)", dir, perm) + return fmt.Errorf("directory %s is accessible by other users (mode %o)", dir, perm) } if !ownedByCurrentUser(info) { - return fmt.Errorf("socket directory %s is not owned by the current user", dir) + return fmt.Errorf("directory %s is not owned by the current user", dir) } return nil } diff --git a/internal/env/env.go b/internal/env/env.go index f5fd26a..c43a310 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -215,3 +215,79 @@ func Find(name string) (string, error) { return "", fmt.Errorf("%s not found", name) } + +// ResolveEnvFile returns the absolute path of the .env file to use with the config file +// configPath, searching from the directory cwd. name is the --env value. +// +// - A name with a directory part is used as is, relative to cwd. +// - Project config (outside the home directory root): the file is searched from cwd up +// to the config directory, never above; if not found, it goes next to the config. +// - Global config (in the home directory root): the file is searched from cwd up to, +// but excluding, the home directory; if not found, it goes in cwd. +// +// The home directory itself is never searched: a project without .env must not write +// its token to ~/.env, nor share it with every other project. +func ResolveEnvFile(name, configPath, cwd, home string) (string, error) { + if filepath.IsAbs(name) { + return filepath.Clean(name), nil + } + if strings.ContainsRune(name, os.PathSeparator) || strings.ContainsRune(name, '/') { + return filepath.Join(cwd, name), nil + } + + absConfig, err := filepath.Abs(configPath) + if err != nil { + return "", err + } + configDir := filepath.Dir(absConfig) + cwd = filepath.Clean(cwd) + home = filepath.Clean(home) + + if home == "" || home == "." || configDir != home { + // Project config + if isWithin(cwd, configDir) { + if found, ok := searchUp(name, cwd, configDir, true); ok { + return found, nil + } + } + return filepath.Join(configDir, name), nil + } + + // Global config + if found, ok := searchUp(name, cwd, home, false); ok { + return found, nil + } + return filepath.Join(cwd, name), nil +} + +// searchUp looks for name from dir up to stop (included if inclusive), or up to the +// filesystem root if dir is not within stop. +func searchUp(name, dir, stop string, inclusive bool) (string, bool) { + bounded := isWithin(dir, stop) + for { + if bounded && dir == stop && !inclusive { + return "", false + } + path := filepath.Join(dir, name) + if info, err := os.Stat(path); err == nil && !info.IsDir() { + return path, true + } + if bounded && dir == stop { + return "", false + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + dir = parent + } +} + +// isWithin reports whether path is dir or one of its subdirectories. +func isWithin(path, dir string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))) +} diff --git a/internal/env/env_test.go b/internal/env/env_test.go index e81938e..582d513 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -262,3 +262,61 @@ func TestManager_Update_Symlink(t *testing.T) { t.Errorf("target file content = %q", content) } } + +func TestResolveEnvFile(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + project := filepath.Join(home, "dev", "project") + sub := filepath.Join(project, "src", "pkg") + other := filepath.Join(home, "dev", "other") + for _, d := range []string{sub, other} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + touch := func(p string) { + t.Helper() + if err := os.WriteFile(p, nil, 0o600); err != nil { + t.Fatal(err) + } + } + projectConfig := filepath.Join(project, "authk.cue") + globalConfig := filepath.Join(home, "authk.cue") + // Never used: the home directory is not searched + touch(filepath.Join(home, ".env")) + // Above the project: never used for a project config + touch(filepath.Join(home, "dev", ".env")) + + tests := []struct { + name, env, config, cwd, want string + setup func() + }{ + {name: "project, no .env: next to the config", env: ".env", config: projectConfig, cwd: sub, + want: filepath.Join(project, ".env")}, + {name: "project, cwd outside: next to the config", env: ".env", config: projectConfig, cwd: other, + want: filepath.Join(project, ".env")}, + {name: "explicit path is made absolute", env: "front/.env", config: projectConfig, cwd: project, + want: filepath.Join(project, "front", ".env")}, + {name: "project, nearest .env in a subdirectory", env: ".env", config: projectConfig, cwd: sub, + setup: func() { touch(filepath.Join(project, "src", ".env")) }, + want: filepath.Join(project, "src", ".env")}, + {name: "global config, no .env: in cwd, not ~/.env nor ~/dev/.env", env: ".env.local", config: globalConfig, cwd: other, + want: filepath.Join(other, ".env.local")}, + {name: "global config, .env found in a parent below home", env: ".env", config: globalConfig, cwd: other, + want: filepath.Join(home, "dev", ".env")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + tt.setup() + } + got, err := ResolveEnvFile(tt.env, tt.config, tt.cwd, home) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("got %s, want %s", got, tt.want) + } + }) + } +}