Remove broker
CI / build (push) Successful in 6s

This commit is contained in:
2026-05-25 10:37:21 +02:00
parent 154412b0a7
commit c9081ee2da
40 changed files with 902 additions and 2504 deletions
+1 -2
View File
@@ -9,8 +9,7 @@ Design + phasing: [plan.md](plan.md).
- [Architecture](docs/architecture.md)
- [Auth model](docs/auth-model.md)
- [Storage & keyring](docs/storage.md)
- [Broker RPC](docs/rpc.md)
- [Service registry](docs/service-registry.md)
- [Agent profiles](docs/agents.md)
- [Agents](docs/agents.md)
- [gssh integration](docs/gssh-integration.md)
- [Conventions](docs/conventions.md)
-207
View File
@@ -1,207 +0,0 @@
// Command sherlock-broker is the long-running per-user daemon that
// owns the Authentik OAuth state, caches tokens in the OS keyring,
// hosts the loopback HTTP listener for browser callbacks, and answers
// JSON-over-newline RPCs on $XDG_RUNTIME_DIR/sherlock.sock.
//
// Decisions locked in Phase 1:
// - OS keyring for token persistence (probe at startup, fail fast).
// - JSON-over-newline framing on the UDS RPC.
// - Forked-child lifecycle: the wrapper CLI starts the broker as a
// setsid-detached process; this binary refuses to start twice via
// a PID-file flock.
// - Loopback HTTP callback on 127.0.0.1:6990.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/broker"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/socket"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
)
// Version is overwritten at build time via -ldflags "-X main.Version=...".
var Version = "0.0.0-dev"
const (
envIssuer = "SHERLOCK_AUTHENTIK_ISSUER"
envClientID = "SHERLOCK_AUTHENTIK_CLIENT_ID"
envScopes = "SHERLOCK_AUTHENTIK_SCOPES"
envIdleTimeout = "SHERLOCK_BROKER_IDLE"
defaultScopes = "openid profile email offline_access"
defaultIdleTimeout = time.Hour
)
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, `sherlock-broker - Charlie credential broker daemon
Usage:
sherlock-broker <subcommand>
Subcommands:
daemon Run the broker (foreground; the wrapper CLI usually invokes
this via fork+setsid). Refuses to start if another broker
holds the per-user PID lock.
version Print the broker version and exit.
Environment:
%s Authentik OIDC issuer URL (required for login).
%s Authentik OAuth2 client ID (required for login).
%s Space-separated scopes (default: "%s").
%s Idle timeout, e.g. 30m, 1h (default: %s).
`,
envIssuer, envClientID, envScopes, defaultScopes,
envIdleTimeout, defaultIdleTimeout)
}
flag.Parse()
args := flag.Args()
if len(args) == 0 {
flag.Usage()
os.Exit(2)
}
switch args[0] {
case "version", "--version", "-v":
fmt.Println(Version)
case "daemon":
runDaemon()
default:
fmt.Fprintf(os.Stderr, "sherlock-broker: unknown subcommand %q\n", args[0])
flag.Usage()
os.Exit(2)
}
}
func runDaemon() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.SetPrefix("sherlock-broker: ")
if err := keyring.Probe(); err != nil {
fmt.Fprintln(os.Stderr, "sherlock-broker:", err)
fmt.Fprintln(os.Stderr, "Hint:", keyring.RemediationHint())
os.Exit(3)
}
pidPath, err := xdg.PidPath()
if err != nil {
log.Fatalf("pid path: %v", err)
}
lock, err := broker.AcquirePidLock(pidPath)
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock-broker:", err)
os.Exit(4)
}
defer func() {
if err := lock.Release(); err != nil {
log.Printf("pidlock release: %v", err)
}
}()
cfg := loadConfig()
flow := authn.NewFlow(cfg, nil)
defer func() {
if err := flow.Close(); err != nil {
log.Printf("flow close: %v", err)
}
}()
refresher := authn.NewRefresher(cfg, nil)
rootCtx, cancel := context.WithCancel(context.Background())
defer cancel()
methods := &broker.Methods{
Store: keyring.NewStore(),
Flow: flow,
Refresher: refresher,
Cfg: cfg,
ShutdownFn: cancel,
AgentNames: agent.Names,
}
svc := broker.NewService(methods.MethodTable())
sockPath, err := xdg.SocketPath()
if err != nil {
log.Fatalf("socket path: %v", err)
}
srv, err := socket.Listen(sockPath, svc)
if err != nil {
log.Fatalf("listen: %v", err)
}
log.Printf("listening on %s (pid=%d)", sockPath, os.Getpid())
// Signal handler — graceful shutdown on SIGINT/SIGTERM.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
s := <-sigCh
log.Printf("received %s, shutting down", s)
cancel()
}()
// Idle-timeout watcher.
idle := loadIdleTimeout()
if idle > 0 {
go watchIdle(rootCtx, svc, idle, cancel)
}
if err := srv.Serve(rootCtx); err != nil {
log.Printf("serve: %v", err)
}
log.Printf("shutdown complete")
}
func watchIdle(ctx context.Context, svc *broker.Service, idle time.Duration, cancel context.CancelFunc) {
ticker := time.NewTicker(idle / 4)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if time.Since(svc.LastActivity()) >= idle {
log.Printf("idle for %s, exiting", idle)
cancel()
return
}
}
}
}
func loadConfig() authn.Config {
scopes := defaultScopes
if s := os.Getenv(envScopes); s != "" {
scopes = s
}
return authn.Config{
Issuer: os.Getenv(envIssuer),
ClientID: os.Getenv(envClientID),
Scopes: strings.Fields(scopes),
}
}
func loadIdleTimeout() time.Duration {
if v := os.Getenv(envIdleTimeout); v != "" {
d, err := time.ParseDuration(v)
if err == nil {
return d
}
log.Printf("invalid %s=%q: %v (using default)", envIdleTimeout, v, err)
}
return defaultIdleTimeout
}
+89 -118
View File
@@ -1,22 +1,14 @@
// Command sherlock is the operator-facing CLI: login / logout / status
// and the agent-spawning router. It owns no auth state of its own —
// every credential operation is an RPC to sherlock-broker, which it
// transparently forks the first time it's needed (Decision #10).
//
// Routing rules:
//
// sherlock version → print version, no broker
// sherlock login → ensure broker, OAuth flow, persist tokens
// sherlock logout [--shutdown]→ ensure broker, clear tokens, maybe stop it
// sherlock status → ensure broker, print status table
// sherlock run <agent> [...] → ensure broker, load profile, exec agent
// sherlock <agent-name> [...] → alias for `run <agent-name>` when the
//
// first arg matches a loaded profile name
// Command sherlock is the operator's single binary: it logs in to
// Authentik (one-shot loopback HTTP listener), persists tokens in the
// OS keyring, and dispatches `sherlock <agent>` to the matching agent
// CLI (Copilot, Claude Code, …). There is no daemon — refresh races
// across concurrent invocations are serialised via flock on a
// per-user lock file. See docs/architecture.md.
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
@@ -26,24 +18,26 @@ import (
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/broker"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/rpc"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/socket"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
)
// Version is overwritten at build time via -ldflags "-X main.Version=...".
var Version = "0.0.0-dev"
const (
exitOK = 0
exitGeneric = 1
exitUsage = 2
exitKeyring = 3
exitBroker = 4
exitAuth = 5
exitLoginWait = 5 * time.Minute
exitGeneric = 1
exitUsage = 2
exitKeyring = 3
exitAuth = 5
)
const (
envIssuer = "SHERLOCK_AUTHENTIK_ISSUER"
envClientID = "SHERLOCK_AUTHENTIK_CLIENT_ID"
envScopes = "SHERLOCK_AUTHENTIK_SCOPES"
defaultScopes = "openid profile email offline_access"
)
func main() {
@@ -51,7 +45,6 @@ func main() {
usage(os.Stderr)
os.Exit(exitUsage)
}
sub := os.Args[1]
rest := os.Args[2:]
@@ -64,7 +57,6 @@ func main() {
return
}
// Every non-trivial subcommand needs the keyring up.
if err := keyring.Probe(); err != nil {
fmt.Fprintln(os.Stderr, "sherlock:", err)
fmt.Fprintln(os.Stderr, "Hint:", keyring.RemediationHint())
@@ -85,7 +77,6 @@ func main() {
}
runAgent(rest[0], rest[1:])
default:
// Agent alias: `sherlock copilot ...` -> `run copilot ...`
if _, ok := agent.Get(sub); ok {
runAgent(sub, rest)
return
@@ -104,116 +95,109 @@ Usage:
Subcommands:
login Open a browser, log in to Authentik, persist tokens.
logout [--shutdown] Clear tokens; optionally stop the broker daemon.
logout Clear stored tokens.
status Show login state, token expiry, available agents.
run <agent> [args...] Spawn an agent with broker-injected credentials.
run <agent> [args...] Spawn an agent.
<agent> [args...] Alias for `+"`run <agent> ...`"+`. Known agents: %s.
version Print the sherlock version and exit.
Environment (consumed by the broker, set before `+"`sherlock login`"+`):
SHERLOCK_AUTHENTIK_ISSUER, SHERLOCK_AUTHENTIK_CLIENT_ID,
SHERLOCK_AUTHENTIK_SCOPES (optional), SHERLOCK_BROKER_IDLE (optional).
`, strings.Join(agent.Names(), ", "))
Environment (consumed by `+"`sherlock login`"+`):
%s Authentik OIDC issuer URL (required).
%s Authentik OAuth2 client ID (required).
%s Space-separated scopes (default: %q).
`,
strings.Join(agent.Names(), ", "),
envIssuer, envClientID, envScopes, defaultScopes)
}
func dialBroker(ctx context.Context) *socket.Client {
sockPath, err := xdg.SocketPath()
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock: socket path:", err)
os.Exit(exitBroker)
func loadAuthnConfig() authn.Config {
scopes := defaultScopes
if v := os.Getenv(envScopes); v != "" {
scopes = v
}
cli, err := broker.EnsureRunning(ctx, broker.EnsureRunningOptions{SocketPath: sockPath})
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock: cannot reach broker:", err)
os.Exit(exitBroker)
return authn.Config{
Issuer: os.Getenv(envIssuer),
ClientID: os.Getenv(envClientID),
Scopes: strings.Fields(scopes),
}
return cli
}
func runLogin(args []string) {
fs := flag.NewFlagSet("login", flag.ExitOnError)
timeout := fs.Duration("timeout", exitLoginWait, "how long to wait for browser callback")
timeout := fs.Duration("timeout", 5*time.Minute, "how long to wait for the browser callback")
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), *timeout+10*time.Second)
cfg := loadAuthnConfig()
if !cfg.Configured() {
fmt.Fprintf(os.Stderr,
"sherlock: Authentik not configured. Set %s and %s in your environment.\n",
envIssuer, envClientID)
os.Exit(exitAuth)
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cli := dialBroker(ctx)
defer func() { _ = cli.Close() }()
var start broker.LoginStartResult
if err := cli.Call(ctx, rpc.MethodLoginStart, struct{}{}, &start); err != nil {
fmt.Fprintln(os.Stderr, "sherlock: login_start:", err)
os.Exit(exitAuth)
}
fmt.Println("Opening browser for Authentik login...")
fmt.Println("If it doesn't open, visit:")
fmt.Println(" ", start.AuthURL)
if err := openBrowser(start.AuthURL); err != nil {
fmt.Fprintln(os.Stderr, "sherlock: could not open browser:", err)
opts := authn.LoginOptions{
OnAuthURL: func(u string) {
fmt.Println("Opening browser to:")
fmt.Println(" ", u)
if err := openBrowser(u); err != nil {
fmt.Fprintln(os.Stderr, "sherlock: open browser:", err)
fmt.Println("(Browser didn't open visit the URL above manually.)")
}
},
}
wait := broker.LoginWaitParams{State: start.State, TimeoutSeconds: int(timeout.Seconds())}
if err := cli.Call(ctx, rpc.MethodLoginWait, wait, nil); err != nil {
fmt.Fprintln(os.Stderr, "sherlock: login failed:", err)
res, err := authn.Login(ctx, cfg, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock: login:", err)
os.Exit(exitAuth)
}
fmt.Println("Logged in.")
if err := keyring.NewStore().SetAuthentikTokens(res.Tokens); err != nil {
fmt.Fprintln(os.Stderr, "sherlock: persist tokens:", err)
os.Exit(exitKeyring)
}
fmt.Printf("Logged in as %s <%s>.\n", res.Tokens.Name, res.Tokens.Email)
}
func runLogout(args []string) {
fs := flag.NewFlagSet("logout", flag.ExitOnError)
shutdown := fs.Bool("shutdown", false, "also stop the broker daemon")
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cli := dialBroker(ctx)
defer func() { _ = cli.Close() }()
if err := cli.Call(ctx, rpc.MethodLogout, struct{}{}, nil); err != nil {
func runLogout(_ []string) {
if err := keyring.NewStore().ClearAuthentikTokens(); err != nil {
fmt.Fprintln(os.Stderr, "sherlock: logout:", err)
os.Exit(exitAuth)
os.Exit(exitKeyring)
}
fmt.Println("Logged out.")
if *shutdown {
// Best-effort: the broker tears down the socket as part of
// shutdown so the response may race with EOF.
_ = cli.Call(ctx, rpc.MethodShutdown, struct{}{}, nil)
fmt.Println("Broker stopped.")
}
}
func runStatus(_ []string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cli := dialBroker(ctx)
defer func() { _ = cli.Close() }()
var st broker.StatusResult
if err := cli.Call(ctx, rpc.MethodStatus, struct{}{}, &st); err != nil {
fmt.Fprintln(os.Stderr, "sherlock: status:", err)
os.Exit(exitGeneric)
}
if !st.LoggedIn {
store := keyring.NewStore()
ts, err := store.GetAuthentikTokens()
switch {
case errors.Is(err, keyring.ErrNoTokens):
fmt.Println("Not logged in.")
} else {
fmt.Printf("Logged in as %s <%s>\n", st.Name, st.Email)
fmt.Printf(" subject: %s\n", st.Subject)
if st.IDExpiresAt != nil {
case err != nil:
fmt.Fprintln(os.Stderr, "sherlock: status:", err)
os.Exit(exitKeyring)
default:
fmt.Printf("Logged in as %s <%s>\n", ts.Name, ts.Email)
fmt.Printf(" subject: %s\n", ts.Subject)
fmt.Printf(" issuer: %s\n", ts.Issuer)
if !ts.IDExpiresAt.IsZero() {
fmt.Printf(" id token until: %s (%s)\n",
st.IDExpiresAt.Format(time.RFC3339),
humanizeUntil(*st.IDExpiresAt))
ts.IDExpiresAt.Format(time.RFC3339),
humanizeUntil(ts.IDExpiresAt))
}
if st.RefreshExpAt != nil {
if !ts.RefreshExpAt.IsZero() {
fmt.Printf(" refresh until: %s (%s)\n",
st.RefreshExpAt.Format(time.RFC3339),
humanizeUntil(*st.RefreshExpAt))
ts.RefreshExpAt.Format(time.RFC3339),
humanizeUntil(ts.RefreshExpAt))
}
}
if len(st.Agents) > 0 {
if names := agent.Names(); len(names) > 0 {
fmt.Println("Available agents:")
for _, a := range st.Agents {
fmt.Printf(" - %s\n", a)
for _, n := range names {
a, _ := agent.Get(n)
fmt.Printf(" - %-10s %s\n", n, a.Description())
}
}
}
@@ -224,20 +208,7 @@ func runAgent(name string, userArgs []string) {
fmt.Fprintf(os.Stderr, "sherlock: unknown agent %q. Known: %v\n", name, agent.Names())
os.Exit(exitUsage)
}
// Ensure broker is up so future MCPs (Phase 2+) can reach it.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
cli := dialBroker(ctx)
_ = cli.Close()
cancel()
sockPath, err := xdg.SocketPath()
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock: socket path:", err)
os.Exit(exitGeneric)
}
if err := a.Spawn(agent.Context{BrokerSocket: sockPath}, userArgs); err != nil {
if err := a.Spawn(agent.Context{}, userArgs); err != nil {
fmt.Fprintln(os.Stderr, "sherlock:", err)
os.Exit(exitGeneric)
}
+8 -9
View File
@@ -8,7 +8,7 @@ How sherlock decides which CLI to spawn when you type
| Name | Binary | MCP config flag | Notes |
|---|---|---|---|
| `copilot` | `copilot` (npm `@github/copilot`) | `--additional-mcp-config @<path>` | Augments user's `~/.copilot/mcp-config.json`. JSON shape is the VS Code MCP schema (`{"servers": ...}`). |
| `claude` | `claude` (npm `@anthropic-ai/claude-code`) | `--mcp-config <path>` | JSON shape is Claude Code's `.mcp.json` schema (`{"mcpServers": ...}`). `ANTHROPIC_API_KEY` is stripped from the child env so a personal key can't override the broker-managed session. |
| `claude` | `claude` (npm `@anthropic-ai/claude-code`) | `--mcp-config <path>` | JSON shape is Claude Code's `.mcp.json` schema (`{"mcpServers": ...}`). `ANTHROPIC_API_KEY` is stripped from the child env so a personal key can't override the sherlock-managed session. |
## Routing
@@ -26,16 +26,15 @@ bare alias is the daily-driver form.
## What sherlock does per spawn
1. `keyring.Probe()` — fail fast if the OS keyring isn't available.
2. `broker.EnsureRunning` — dial the broker socket, fork it if absent.
3. Resolve the agent binary on `$PATH`. Friendly error if missing.
4. Render the per-agent MCP config to
2. Resolve the agent binary on `$PATH`. Friendly error if missing.
3. Render the per-agent MCP config to
`$XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json` (0600). In Phase 1 the
servers map is always empty; Phase 2 populates it from `services.d/`.
5. Build the child argv with the agent-specific flag.
6. Build the child env: parent env → drop forbidden keys → overlay
`SHERLOCK_SOCKET=<path>` so future MCPs (Phase 2+) can reach the
broker.
7. `syscall.Exec` — sherlock disappears, the agent takes its place.
4. Build the child argv with the agent-specific flag.
5. Build the child env: parent env minus per-agent forbids. MCPs
spawned by the agent will read tokens straight from the OS keyring
(Phase 2+).
6. `syscall.Exec` — sherlock disappears, the agent takes its place.
## Adding a new agent
+36 -37
View File
@@ -4,58 +4,57 @@ Condensed from [`../plan.md`](../plan.md). Read this first; jump to the plan for
## One-paragraph summary
Sherlock is a per-user credential broker + agent-CLI wrapper that runs on the operator's workstation. It owns a single Authentik session, exchanges it for per-service tokens on demand, injects those tokens as environment variables into thin stdio MCP servers, and then `exec`s the agent CLI of your choice (Copilot, Claude Code, …) with the right MCP config. No long-lived service tokens live on disk in the clear, and the agent never sees a credential it isn't supposed to.
Sherlock is a per-user credential broker + agent-CLI wrapper that runs on the operator's workstation. It owns a single Authentik session (persisted in the OS keyring), exchanges it for per-service tokens on demand, injects those tokens as environment variables into thin stdio MCP servers, and then `exec`s the agent CLI of your choice (Copilot, Claude Code, …) with the right MCP config. No long-lived service tokens live on disk in the clear, and the agent never sees a credential it isn't supposed to.
## Diagram
```
┌────────────────────────────────────────────────────────────┐
user
│ │ │
│ │ `sherlock run copilot` (or `sherlock copilot`) │
│ ▼ │
│ ┌─────────────┐ spawns, injects env ┌──────────────┐ │
│ │ copilot CLI │ ────────────────────▶ │ gitea-mcp │ │
│ │ │ │ grafana-mcp │ │
│ │ │ │ gssh-mcp │ │
│ └─────┬───────┘ └──────┬───────┘ │
│ │ unix socket │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ sherlock-broker (systemd --user daemon) │ │
│ │ - owns the Authentik OAuth state │ │
│ │ - per-service token cache + refresh │ │
│ │ - RFC 8693 token exchange │ │
│ │ - hosts loopback HTTP for browser callbacks │ │
│ │ - exposes sherlock-mcp (stdio) for consent flow │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ OIDC / token exchange │
│ ▼ │
│ Authentik (id.alexandru.macocian.me) │
└────────────────────────────────────────────────────────────┘
```mermaid
flowchart TD
user["user shell"] -->|`sherlock copilot`<br/>or `sherlock run copilot`| sh[sherlock CLI]
sh -->|syscall.Exec| agent[copilot / claude CLI]
sh -.->|writes MCP config| mcpfile[(XDG_RUNTIME_DIR/sherlock/&lt;agent&gt;.mcp.json)]
agent -.->|reads| mcpfile
agent -->|stdio| gitea[gitea-mcp]
agent -->|stdio| grafana[grafana-mcp]
agent -->|stdio| gssh[gssh-mcp]
gitea --> keyring[(OS keyring<br/>Secret Service / Keychain / Cred Mgr)]
grafana --> keyring
gssh --> keyring
sh --> keyring
keyring -->|OIDC refresh on demand<br/>flock-serialised| authentik[Authentik<br/>id.alexandru.macocian.me]
```
## Components
| Component | Lives at | Owns |
|---|---|---|
| `sherlock` (CLI) | `cmd/sherlock/` | `login`, `logout`, `status`, `run`, agent-name aliases (`copilot`, `claude`, …). Renders the per-session MCP config, then `exec`s the agent. |
| `sherlock-broker` (daemon) | `cmd/sherlock-broker/` | Long-running per-user daemon. Authentik PKCE; loopback redirect; token cache in the OS keyring; single-flight refresh; RFC 8693 token exchange; Unix-socket RPC. |
| `sherlock` (CLI) | `cmd/sherlock/` | The only binary. `login`, `logout`, `status`, `run`, agent-name aliases (`copilot`, `claude`, …). At login: spins up a one-shot loopback HTTP listener on `127.0.0.1:6990`, runs PKCE, writes the TokenSet to the OS keyring. At spawn: looks up the agent in `internal/agent/`, renders the per-session MCP config, `exec`s the agent. |
| `internal/agent/` | — | One Go file per supported CLI (`copilot.go`, `claude.go`, …), each registering itself via `init()`. Shared exec/env helpers live alongside. See [agents.md](agents.md). |
| `internal/authn/` | — | OIDC / PKCE / token-exchange primitives. See [auth-model.md](auth-model.md). |
| `internal/mcp/` | — | Per-format MCP-config renderers (VS Code shape for Copilot, `.mcp.json` shape for Claude Code). Shared by every agent. |
| `internal/socket/` | — | UDS RPC server (broker side) + client (CLI + MCPs). |
| `cmd/gitea-mcp/` (Phase 2) | — | First per-service MCP. Reads `GITEA_TOKEN` from env, refreshes via broker socket on 401. |
| `internal/authn/` | — | OIDC discovery, PKCE, one-shot Login, file-locked EnsureFresh. See [auth-model.md](auth-model.md). |
| `internal/keyring/` | — | OS keyring wrapper: `Probe` (called at every CLI start), `TokenSet` persistence. See [storage.md](storage.md). |
| `internal/mcp/` | — | Per-format MCP-config renderers (VS Code shape for Copilot, `.mcp.json` shape for Claude Code). |
| `internal/xdg/` | — | Resolves `$XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json` and the refresh-lock path. |
| `cmd/gitea-mcp/` (Phase 2) | — | First per-service MCP. Reads tokens directly from the keyring (via `internal/authn`); refreshes via flock-serialised EnsureFresh on 401. |
| `cmd/gssh-mcp/` (Phase 3) | — | Thin HTTP+WS client to the existing gssh server. No local certs. See [gssh-integration.md](gssh-integration.md). |
| `cmd/grafana-mcp/` (Phase 4) | — | Grafana tools, OIDC-federated via Authentik. |
| `cmd/sherlock-mcp/` (Phase 4) | — | The only **interactive** MCP. Exposes `oauth.consent(service)`, `auth.whoami()`, `auth.list_services()`, `auth.revoke(service)`. |
## Why the broker is separate from the wrapper
## Why there is no daemon
- One Authentik browser flow per workstation, shared across every terminal.
- A stable loopback listener for non-Authentik OAuth callbacks.
- Token refresh races are solved once, in one place.
- The wrapper can stay agent-agnostic: it only knows "ask the broker for a token, then exec the agent". Swap Copilot for Claude Code without touching auth code.
The original Phase 1 design had a separate `sherlock-broker` daemon (forked-child, UDS RPC, PID-file flock, idle timer). It was removed in the post-Phase-1 refactor — Decisions #9 (JSON-over-newline RPC) and #10 (forked-child broker) are both superseded.
Reasoning:
- The OS keyring is already the single source of truth across processes; a daemon caching it in RAM is redundant on the homelab single-user scale.
- The loopback HTTP listener for PKCE only needs to be alive during `sherlock login` (a few seconds). No reason to keep it running.
- Cross-process refresh races (multiple MCPs hitting 401 simultaneously) are solved by an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock` with the canonical "take lock → re-read → maybe refresh" pattern.
- Service-specific OAuth (Phase 2 own-oauth services like GitHub.com) gets its own explicit `sherlock auth <service>` command that spins up a fresh listener for the duration of the flow.
What we lose: a small amount of latency overhead per refresh (libsecret D-Bus round-trip ~1ms) and the ability to handle a mid-session OAuth pop-up without an explicit command. What we gain: no daemon lifecycle, no PID files, no IPC protocol, no idle timers, ~800 LoC less, and "is sherlock-broker still running" stops being a debugging path.
## Boundaries
+16 -19
View File
@@ -4,8 +4,8 @@ How sherlock obtains and distributes credentials.
## The two layers
1. **Operator → Authentik.** A single OAuth 2.1 authorization-code + PKCE flow against Authentik (`id.alexandru.macocian.me`). The broker owns the resulting ID/access/refresh tokens. This is the only browser flow per workstation per session.
2. **Broker → downstream service.** For each per-service request from an MCP, the broker mints a service-scoped token using one of the three grant kinds below.
1. **Operator → Authentik.** A single OAuth 2.1 authorization-code + PKCE flow against Authentik (`id.alexandru.macocian.me`), driven by `sherlock login`. The resulting ID/access/refresh tokens land in the OS keyring (see [storage.md](storage.md)). This is the only browser flow per workstation per session.
2. **MCP → downstream service.** Each per-service MCP reads the operator's stored tokens from the keyring (via `internal/authn`) and mints a service-scoped token using one of the three grant kinds below. There is no daemon mediating this — see [architecture.md#why-there-is-no-daemon](architecture.md#why-there-is-no-daemon).
## Grant kinds
@@ -13,44 +13,41 @@ Set per service in `~/.config/sherlock/services.d/<name>.toml` (see [service-reg
### `oidc-federated`
The downstream service already federates against Authentik (Gitea, Grafana, Caddy-protected apps). The broker either:
The downstream service already federates against Authentik (Gitea, Grafana, Caddy-protected apps). The MCP either:
- Hands the existing Authentik-issued ID token (when the service's expected audience matches), or
- Performs **RFC 8693 token exchange** against Authentik's token endpoint, swapping the operator's ID token for a service-scoped access token.
The MCP receives the token in an env var (e.g. `GITEA_TOKEN`).
The MCP receives the token in an env var (e.g. `GITEA_TOKEN`) injected by sherlock at agent spawn.
### `own-oauth`
The service runs its own OAuth/OIDC stack, not federated through Authentik (think GitHub.com, a SaaS API, …). The broker:
- Holds a per-(operator, service) OAuth client registration.
- Runs the auth-code + PKCE flow in a browser the first time, with the callback received on the broker's loopback listener.
- Caches the refresh token alongside the Authentik creds.
- Refreshes silently after that.
The service runs its own OAuth/OIDC stack, not federated through Authentik (think GitHub.com, a SaaS API, …). Triggered by an explicit `sherlock auth <service>` command:
Triggered explicitly via `oauth.consent(service)` from `sherlock-mcp` (Phase 4) or via `sherlock login --service <name>`.
- The CLI spins up a fresh loopback listener (separate from the Authentik one on :6990).
- Runs the auth-code + PKCE flow in a browser, with the callback received on that listener.
- Persists the refresh token alongside the Authentik creds, keyed under `service:<name>` in the keyring.
- Subsequent MCP runs refresh silently from the stored refresh token.
### `static-pat`
For services that have no OAuth at all, or where the only sane option is a long-lived PAT. The broker reads the PAT from an `age`-encrypted blob and injects it. Avoid where possible; document why every time it's used.
For services that have no OAuth at all, or where the only sane option is a long-lived PAT. The operator stores the PAT in the keyring once via `sherlock auth <service> --pat`, and the MCP reads it from there. Avoid where possible; document why every time it's used.
## Token storage
See [storage.md](storage.md) for the full decision (OS keyring via
`zalando/go-keyring`, pre-flight at startup, exit code 3 on failure).
See [storage.md](storage.md) for the full decision (OS keyring via `zalando/go-keyring`, pre-flight at startup of every CLI invocation, exit code 3 on failure).
- Authentik creds persist in the OS keyring (Decision #8); per-service
tokens follow the same pattern in Phase 2+.
- In-memory cache mirrors the keyring; writes happen on rotation.
- Refresh is single-flight (`golang.org/x/sync/singleflight`): a 401
storm across N MCPs collapses to one refresh.
- Authentik creds persist in the OS keyring (Decision #8); per-service tokens follow the same pattern in Phase 2+.
- Refresh races across concurrent processes (multiple MCPs hitting 401 at once) are serialised via an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock`. The canonical "take lock → re-read keyring → maybe refresh" pattern; see `internal/authn/refresh.go`.
## Audience binding
Per the MCP 2025-06-18 spec, downstream tokens MUST be audience-bound. The broker never hands an MCP a token whose `aud` claim doesn't name that service. This is the "confused deputy" mitigation the spec calls out.
Per the MCP 2025-06-18 spec, downstream tokens MUST be audience-bound. The MCP never hands its tool implementation a token whose `aud` claim doesn't name the right service. This is the "confused deputy" mitigation the spec calls out.
## Scope minimisation
Default service registrations request read-only scopes. Write scopes require either:
- The operator passing `--write` to `sherlock run` (Phase 5), or
- An explicit `scopes` override in the service's TOML.
+4 -4
View File
@@ -34,11 +34,11 @@ No top-level `pkg/` until we have an external consumer.
- **One topic per file under `docs/`.** Never append a new section to an existing doc to cover a new concern; create `docs/<new-topic>.md` and link it from `README.md`.
- `plan.md` is the long-form design + phasing doc. It is allowed to be long.
- Cross-link aggressively: every doc should link to the other docs whose concerns it touches.
- ASCII diagrams over images. Diagrams live next to the prose that explains them.
- Mermaid diagrams over images or ASCII art. Diagrams live next to the prose that explains them.
## Naming
- Binaries are kebab-case (`sherlock-broker`, `gitea-mcp`).
- Binaries are kebab-case (`gitea-mcp`, `gssh-mcp`).
- Built-in agent profiles match the agent's canonical CLI name (`copilot`, `claude`, `aider`).
- Service registry files match the service's canonical name (`gitea.toml`, `grafana.toml`).
- Env vars are `<SERVICE>_TOKEN` (e.g. `GITEA_TOKEN`, `GSSH_TOKEN`).
@@ -46,11 +46,11 @@ No top-level `pkg/` until we have an external consumer.
## Extensibility invariants
- **Agent extensibility:** adding a new agent CLI is a Go file under `internal/agent/` registering itself via `init()`. See [agents.md](agents.md). The TOML-overlay design was tried and dropped — per-CLI quirks (auth subcommands, flag schemas, MCP config shapes) deserve a real code home.
- **Service extensibility:** adding a new downstream service MUST be a TOML drop-in under `~/.config/sherlock/services.d/` + (optionally) a new `cmd/<service>-mcp/` binary. The broker itself does not learn about individual services in Go code.
- **Service extensibility:** adding a new downstream service is a TOML drop-in under `~/.config/sherlock/services.d/` + (optionally) a new `cmd/<service>-mcp/` binary. Sherlock's own code does not learn about individual services.
## Commits
- Conventional-ish: `area: short imperative` (e.g. `broker: persist tokens with age`, `docs: add gssh-integration`).
- Conventional-ish: `area: short imperative` (e.g. `authn: persist client_id in TokenSet`, `docs: add gssh-integration`).
- One logical change per commit. CI must pass on every commit on `main`.
## CI
+2 -2
View File
@@ -16,14 +16,14 @@ That's the entire job of "let an authorized human run a command on a Charlie hos
A thin Go HTTP+WebSocket client to the existing gssh server, wrapped in a stdio MCP. It:
1. Reads `GSSH_TOKEN` from env at startup (an Authentik JWT minted by the broker, `aud=gssh`).
1. Reads `GSSH_TOKEN` from env at startup (an Authentik JWT for `aud=gssh`, written into the env by sherlock at agent spawn from the operator's stored TokenSet).
2. Calls `POST /api/v1/session/initialize` with `Authorization: Bearer <jwt>` to materialise the user's session on the gssh server.
3. Caches the list of permitted hosts from `GET /api/v1/session/hosts`.
4. Exposes MCP tools:
- `ssh.list_hosts()` — returns the cached host list.
- `ssh.run(host, command, timeout?)` — opens a WebSocket to the session route for `host`, writes `command + "; echo __SHERLOCK_DONE__$?\n"`, reads until it sees the sentinel, returns `{stdout, exit_code}`.
- `ssh.put_file(host, path, content)` (later) — same shell channel, base64-decode + `tee` on the far side, sentinel as above.
5. On 401, asks the broker for a refreshed token via the UDS RPC and retries once.
5. On 401, re-reads the keyring (calling `authn.EnsureFresh`, which `flock`-serialises against concurrent MCP refreshes) and retries once.
No SSH key handling. No cert minting. No `~/Dev/gssh` shell-out.
-77
View File
@@ -1,77 +0,0 @@
# Broker RPC
The wire protocol between `sherlock` (CLI) and `sherlock-broker` (daemon).
## Framing — Decision #9
JSON-over-newline on a Unix domain socket at
`$XDG_RUNTIME_DIR/sherlock.sock`.
- One JSON object per line, `\n`-terminated, both directions.
- UTF-8.
- Each connection is independent; the client serialises its own writes,
the server fans out one goroutine per connection.
- Debuggable with `socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/sherlock.sock`.
### Request
```json
{ "id": 7, "method": "status", "params": {} }
```
### Response
```json
{ "id": 7, "result": { ... } }
```
or
```json
{ "id": 7, "error": { "code": "not_logged_in", "message": "no Authentik tokens stored" } }
```
`id` echoes the request `id`. `result` and `error` are mutually
exclusive; exactly one is present (the other is `null`/omitted).
## Method catalogue
| Method | Params | Result |
|---|---|---|
| `status` | `{}` | `{ logged_in, sub?, name?, email?, id_expires_at?, refresh_expires_at?, issuer?, authn_configured, agents[] }` |
| `whoami` | `{}` | `{ sub, name, email }` — errors with `not_logged_in` if no tokens. |
| `login_start` | `{}` | `{ auth_url, state }` — starts the loopback listener if not already up. |
| `login_wait` | `{ state, timeout_seconds }` | `{}` — blocks until the OAuth callback for `state` arrives or the timeout elapses. |
| `logout` | `{}` | `{}` — idempotent; clears the keyring entry. |
| `get_id_token` | `{}` | `{ token, expires_at }` — refreshes via `refresh_token` if needed (single-flight). |
| `shutdown` | `{}` | `{}` — broker exits after flushing the response. |
## Error codes
| Code | Meaning |
|---|---|
| `invalid_request` | Malformed JSON, missing `method`, or bad `params`. |
| `unknown_method` | Server doesn't recognise `method`. |
| `not_logged_in` | Operation requires a live token set; none is stored. |
| `not_configured` | Broker is missing Authentik issuer/client ID (Decision #11 deferral). The error message names the env vars to set. |
| `flow_expired` | `login_wait` timed out or the in-flight state was cleared. |
| `keyring_unavailable` | Keyring write/read failed mid-session (rare; probe runs at startup). |
| `internal` | Anything else — message contains the underlying error. |
Codes are stable strings, not numbers. The CLI matches on them when it
needs branching behaviour (e.g. `sherlock login` suppresses
`already_in_flight` and just calls `login_wait`).
## Lifecycle interaction
Per [Decision #10](../plan.md), the wrapper CLI auto-forks the broker
when the socket isn't there. Concretely:
1. CLI dials `$XDG_RUNTIME_DIR/sherlock.sock`.
2. On `ECONNREFUSED`/`ENOENT`: `fork+exec sherlock-broker daemon`
with `setsid`, stdin/out/err pointed at `$XDG_RUNTIME_DIR/sherlock.log`.
3. CLI polls the socket every 50ms for up to 2s, then retries the dial.
4. Broker holds an exclusive `flock()` on `sherlock.pid`; a second
`daemon` invocation exits silently if the lock is taken.
5. Broker auto-exits after `SHERLOCK_BROKER_IDLE` of no RPC activity
(default 1h).
+8 -8
View File
@@ -1,10 +1,10 @@
# Service registry
How operators register a downstream service so the broker can mint per-service tokens.
How operators register a downstream service so sherlock can mint per-service tokens.
## Location
`~/.config/sherlock/services.d/<name>.toml` — one file per service. The broker hot-reloads on change.
`~/.config/sherlock/services.d/<name>.toml` — one file per service. Sherlock loads them at the start of each CLI invocation.
A built-in registry baked into the binary would lock the service set at release time. Every service is operator-registered.
@@ -38,7 +38,7 @@ authorization_endpoint = "https://github.com/login/oauth/authorize"
token_endpoint = "https://github.com/login/oauth/access_token"
client_id = "Iv1.abc123"
# client_secret is read from a sibling `<name>.secret.age` file, never inline.
redirect_path = "/cb/github" # appended to the broker's loopback base URL
redirect_path = "/cb/github" # appended to sherlock's loopback base URL during `sherlock auth github`
scopes = ["read:org", "read:user"]
```
@@ -46,7 +46,7 @@ scopes = ["read:org", "read:user"]
```toml
[pat]
# Path to an age-encrypted file containing the PAT. Decrypted by the broker
# Path to an age-encrypted file containing the PAT. Decrypted by sherlock
# on demand using the operator's age key.
file = "~/.config/sherlock/secrets/dockerhub.pat.age"
```
@@ -55,7 +55,7 @@ file = "~/.config/sherlock/secrets/dockerhub.pat.age"
| Field | Required | Notes |
|---|---|---|
| `service.name` | yes | Must equal the filename stem. The broker rejects mismatches. |
| `service.name` | yes | Must equal the filename stem. Sherlock rejects mismatches. |
| `service.kind` | yes | See [auth-model.md](auth-model.md) for the three grant kinds. |
| `service.issuer` | `oidc-federated` only | OIDC issuer URL; used for metadata discovery. |
| `service.audience` | `oidc-federated` only | Goes into the `audience` parameter of the token-exchange request. |
@@ -66,9 +66,9 @@ file = "~/.config/sherlock/secrets/dockerhub.pat.age"
## Loading
1. The broker scans `~/.config/sherlock/services.d/*.toml` on startup.
2. On filesystem change (fsnotify), it re-parses and atomically swaps its registry.
3. Parse errors are logged but do not crash the broker — the offending service is just marked unavailable.
1. Sherlock scans `~/.config/sherlock/services.d/*.toml` at the start of each CLI invocation (`status`, `run <agent>`, etc.).
2. Parse errors are logged to stderr but do not abort — the offending service is just marked unavailable for that invocation.
3. No hot-reload (no daemon). Re-running the command picks up changes.
## Worked examples
+1 -1
View File
@@ -7,7 +7,7 @@ How sherlock persists secrets and runtime state.
| Topic | Decision |
|---|---|
| Token storage | OS keyring via [`github.com/zalando/go-keyring`](https://github.com/zalando/go-keyring). No on-disk credential files, no `age` blobs, no plaintext. |
| Pre-flight | Both `sherlock` and `sherlock-broker` call `keyring.Probe()` at startup. A missing/locked keyring fails fast with a platform-appropriate hint and exit code `3`. |
| Pre-flight | `sherlock` calls `keyring.Probe()` at startup of every subcommand. A missing/locked keyring fails fast with a platform-appropriate hint and exit code `3`. |
| Service name | `sherlock` for real tokens; `sherlock-preflight` for the probe sentinel. |
| Account key | The Authentik `sub` claim once logged in, `default` before. |
| Runtime files | `$XDG_RUNTIME_DIR/sherlock.sock`, `sherlock.pid`, `sherlock.log`, `sherlock/<agent>.mcp.json`. All `0600` (files) / `0700` (dirs). |
-2
View File
@@ -3,12 +3,10 @@ module gitea.alexandru.macocian.me/Charlie/sherlock
go 1.25.0
require (
github.com/BurntSushi/toml v1.6.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/zalando/go-keyring v0.2.8
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
)
require (
-4
View File
@@ -1,5 +1,3 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
@@ -20,8 +18,6 @@ github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPc
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+4 -8
View File
@@ -18,14 +18,10 @@ import (
)
// Context is everything the CLI hands an agent at spawn time. Kept
// deliberately small — anything an agent needs from the broker should
// be reachable via SHERLOCK_SOCKET, not threaded through here.
type Context struct {
// BrokerSocket is the absolute path to the broker's UDS. Set to
// "" when the agent is invoked without a live broker (currently
// never — the CLI always calls EnsureRunning first).
BrokerSocket string
}
// deliberately small — Phase 1 doesn't need anything from sherlock at
// the agent level; Phase 2+ will add fields (e.g. an ID token) as MCPs
// land.
type Context struct{}
// Agent is the contract one concrete type implements per supported CLI.
type Agent interface {
+2 -5
View File
@@ -67,7 +67,7 @@ func TestSpawn_HonoursDefaultExecer(t *testing.T) {
rec := &recordExecer{}
DefaultExecer = rec
if err := (copilot{}).Spawn(Context{BrokerSocket: "/run/s"}, []string{"chat", "hi"}); err != nil {
if err := (copilot{}).Spawn(Context{}, []string{"chat", "hi"}); err != nil {
t.Fatalf("Spawn: %v", err)
}
if !strings.HasSuffix(rec.argv0, "/copilot") {
@@ -86,9 +86,6 @@ func TestSpawn_HonoursDefaultExecer(t *testing.T) {
if !foundAt {
t.Fatalf("argv missing @-prefixed mcp path: %v", rec.argv)
}
if !envEquals(rec.env, "SHERLOCK_SOCKET", "/run/s") {
t.Fatal("SHERLOCK_SOCKET not propagated to child env")
}
}
func TestClaude_ForbidsAnthropicKey(t *testing.T) {
@@ -103,7 +100,7 @@ func TestClaude_ForbidsAnthropicKey(t *testing.T) {
rec := &recordExecer{}
DefaultExecer = rec
if err := (claude{}).Spawn(Context{BrokerSocket: "/run/s"}, []string{"hi"}); err != nil {
if err := (claude{}).Spawn(Context{}, []string{"hi"}); err != nil {
t.Fatalf("Spawn: %v", err)
}
if hasEnv(rec.env, "ANTHROPIC_API_KEY") {
+2 -5
View File
@@ -19,7 +19,7 @@ type claude struct{}
func (claude) Name() string { return "claude" }
func (claude) Description() string { return "Anthropic Claude Code CLI" }
func (c claude) Spawn(ctx Context, args []string) error {
func (c claude) Spawn(_ Context, args []string) error {
bin, err := LookPath("claude")
if err != nil {
return err
@@ -29,10 +29,7 @@ func (c claude) Spawn(ctx Context, args []string) error {
return err
}
argv := c.buildArgv(bin, mcpPath, args)
env := BuildEnv(
[]string{"ANTHROPIC_API_KEY"},
map[string]string{"SHERLOCK_SOCKET": ctx.BrokerSocket},
)
env := BuildEnv([]string{"ANTHROPIC_API_KEY"}, nil)
return DefaultExecer.Exec(bin, argv, env)
}
+2 -5
View File
@@ -18,7 +18,7 @@ type copilot struct{}
func (copilot) Name() string { return "copilot" }
func (copilot) Description() string { return "GitHub Copilot CLI" }
func (c copilot) Spawn(ctx Context, args []string) error {
func (c copilot) Spawn(_ Context, args []string) error {
bin, err := LookPath("copilot")
if err != nil {
return err
@@ -29,10 +29,7 @@ func (c copilot) Spawn(ctx Context, args []string) error {
return err
}
argv := c.buildArgv(bin, mcpPath, args)
env := BuildEnv(nil, map[string]string{
"SHERLOCK_SOCKET": ctx.BrokerSocket,
})
return DefaultExecer.Exec(bin, argv, env)
return DefaultExecer.Exec(bin, argv, BuildEnv(nil, nil))
}
func (copilot) buildArgv(bin, mcpPath string, userArgs []string) []string {
+34 -20
View File
@@ -1,12 +1,17 @@
// Package authn implements the operator-facing OAuth/OIDC flow against
// Authentik: discovery, PKCE, loopback redirect on a fixed port,
// ID-token verification, and single-flight refresh.
// ID-token verification, and refresh.
//
// Decision #11 (Phase 1): redirect URI is fixed at
// http://127.0.0.1:6990/callback (loopback, IANA-unassigned port).
// The Authentik OAuth provider configuration is deferred — the broker
// ships with a clean "not configured" error so we can land the
// scaffolding now and wire the real provider once we can drive a flow.
// Decision #11: redirect URI is fixed at http://127.0.0.1:6990/callback
// (loopback, IANA-unassigned port). The Authentik OAuth provider
// configuration is deferred — Login surfaces a clean "not configured"
// error until SHERLOCK_AUTHENTIK_ISSUER / SHERLOCK_AUTHENTIK_CLIENT_ID
// are set in the operator's environment.
//
// Refactored post-Phase-1: no daemon, no broker, no in-process
// state-table; both Login and EnsureFresh are one-shot functions
// safe to call across concurrent CLI invocations (refresh races are
// resolved via a file lock; see refresh.go).
package authn
import (
@@ -21,26 +26,28 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
)
// LoopbackPort is the fixed loopback port used for the PKCE redirect.
// IANA-unassigned per the Wikipedia port list; locked in this session.
const LoopbackPort = 6990
// LoopbackAddr is the canonical address:port for the PKCE callback.
// IANA-unassigned per the Wikipedia port list; locked in Phase 1.
const LoopbackAddr = "127.0.0.1:6990"
// LoopbackRedirectURL is the canonical redirect URI registered with
// Authentik.
const LoopbackRedirectURL = "http://127.0.0.1:6990/callback"
// Authentik. The path matches Login's HTTP handler.
const LoopbackRedirectURL = "http://" + LoopbackAddr + "/callback"
// Config carries the bits the broker needs to start a flow. Issuer is
// the Authentik application's OIDC issuer URL; ClientID is the OAuth2
// public client ID; Scopes are the OAuth scopes to request.
// Config carries the bits Login needs. Issuer is the Authentik
// application's OIDC issuer URL; ClientID is the OAuth2 public client
// ID; Scopes are the OAuth scopes to request. LoopbackAddrOverride is
// optional and only used by tests.
type Config struct {
Issuer string
ClientID string
Scopes []string
Issuer string
ClientID string
Scopes []string
LoopbackAddrOverride string
}
// Configured reports whether the broker has enough state to start a
// flow. We use this to surface a clean "not configured" error from
// login_start until the real Authentik provider exists.
// Configured reports whether sherlock has enough state to start Login.
// Used to surface a clean "not configured" error until the real
// Authentik provider exists.
func (c Config) Configured() bool {
if c.Issuer == "" || c.ClientID == "" {
return false
@@ -51,6 +58,13 @@ func (c Config) Configured() bool {
return true
}
func (c Config) loopbackAddr() string {
if c.LoopbackAddrOverride != "" {
return c.LoopbackAddrOverride
}
return LoopbackAddr
}
// Discoverer wraps go-oidc's per-issuer discovery so we can fake it
// in tests.
type Discoverer interface {
-339
View File
@@ -1,339 +0,0 @@
package authn
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
)
// FlowResult is what AwaitCallback delivers after a successful exchange.
// The broker turns it into a keyring.TokenSet for persistence.
type FlowResult struct {
IDToken string
AccessToken string
RefreshToken string
IDExpiresAt time.Time
RefreshExpAt time.Time
Issuer string
Subject string
Email string
Name string
}
// ToTokenSet projects a FlowResult into the broker's persistence shape.
func (r FlowResult) ToTokenSet() keyring.TokenSet {
return keyring.TokenSet{
IDToken: r.IDToken,
AccessToken: r.AccessToken,
RefreshToken: r.RefreshToken,
IDExpiresAt: r.IDExpiresAt,
RefreshExpAt: r.RefreshExpAt,
Issuer: r.Issuer,
Subject: r.Subject,
Email: r.Email,
Name: r.Name,
}
}
// Flow owns the loopback HTTP listener and tracks in-flight states.
// Construct one per broker; reuse across logins.
type Flow struct {
cfg Config
discoverer Discoverer
startOnce sync.Once
startErr error
listener net.Listener
server *http.Server
mu sync.Mutex
pending map[string]*pendingFlow
}
type pendingFlow struct {
pkce PKCE
oauthCfg *oauth2.Config
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
result chan flowOutcome
createdAt time.Time
finishedAt time.Time
}
type flowOutcome struct {
res FlowResult
err error
}
// NewFlow returns a configured Flow. The listener is opened lazily on
// the first StartFlow call so unconfigured brokers don't bind the port.
func NewFlow(cfg Config, d Discoverer) *Flow {
if d == nil {
d = DefaultDiscoverer{}
}
return &Flow{
cfg: cfg,
discoverer: d,
pending: make(map[string]*pendingFlow),
}
}
// Close stops the loopback HTTP server. Safe to call zero or many times.
func (f *Flow) Close() error {
if f.server == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return f.server.Shutdown(ctx)
}
// ensureListener starts the loopback HTTP server on first use.
func (f *Flow) ensureListener() error {
f.startOnce.Do(func() {
addr := fmt.Sprintf("127.0.0.1:%d", LoopbackPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
f.startErr = fmt.Errorf("authn: bind %s: %w", addr, err)
return
}
f.listener = ln
mux := http.NewServeMux()
mux.HandleFunc("/callback", f.handleCallback)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
f.server = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() { _ = f.server.Serve(ln) }()
})
return f.startErr
}
// StartResult is returned by StartFlow so the CLI can show the URL and
// then call AwaitCallback with the matching state.
type StartResult struct {
AuthURL string
State string
}
// StartFlow performs OIDC discovery, mints PKCE, registers a pending
// state, and returns the URL the operator should open.
func (f *Flow) StartFlow(ctx context.Context) (StartResult, error) {
if !f.cfg.Configured() {
return StartResult{}, ErrNotConfigured
}
if err := f.ensureListener(); err != nil {
return StartResult{}, err
}
provider, err := f.discoverer.Discover(ctx, f.cfg.Issuer)
if err != nil {
return StartResult{}, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: f.cfg.ClientID})
oauthCfg := &oauth2.Config{
ClientID: f.cfg.ClientID,
Endpoint: provider.Endpoint(),
RedirectURL: LoopbackRedirectURL,
Scopes: f.cfg.Scopes,
}
pkce, err := NewPKCE()
if err != nil {
return StartResult{}, err
}
state, err := newState()
if err != nil {
return StartResult{}, err
}
authURL := oauthCfg.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", pkce.Challenge),
oauth2.SetAuthURLParam("code_challenge_method", pkce.Method),
)
pf := &pendingFlow{
pkce: pkce,
oauthCfg: oauthCfg,
provider: provider,
verifier: verifier,
result: make(chan flowOutcome, 1),
createdAt: time.Now(),
}
f.mu.Lock()
f.pending[state] = pf
f.mu.Unlock()
return StartResult{AuthURL: authURL, State: state}, nil
}
// AwaitCallback blocks until the callback for state arrives, the
// provided context is cancelled, or timeout elapses. The pending state
// is always cleared on return.
func (f *Flow) AwaitCallback(ctx context.Context, state string, timeout time.Duration) (FlowResult, error) {
f.mu.Lock()
pf, ok := f.pending[state]
f.mu.Unlock()
if !ok {
return FlowResult{}, ErrUnknownState
}
defer f.dropPending(state)
if timeout <= 0 {
timeout = 5 * time.Minute
}
t := time.NewTimer(timeout)
defer t.Stop()
select {
case out := <-pf.result:
return out.res, out.err
case <-ctx.Done():
return FlowResult{}, ctx.Err()
case <-t.C:
return FlowResult{}, ErrFlowExpired
}
}
func (f *Flow) dropPending(state string) {
f.mu.Lock()
delete(f.pending, state)
f.mu.Unlock()
}
func (f *Flow) handleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
state := q.Get("state")
f.mu.Lock()
pf, ok := f.pending[state]
f.mu.Unlock()
if !ok {
http.Error(w, "unknown or stale state", http.StatusBadRequest)
return
}
if errParam := q.Get("error"); errParam != "" {
desc := q.Get("error_description")
err := fmt.Errorf("authentik: %s: %s", errParam, desc)
f.deliver(pf, flowOutcome{err: err})
_, _ = w.Write(failPage(errParam, desc))
return
}
code := q.Get("code")
if code == "" {
f.deliver(pf, flowOutcome{err: errors.New("authentik: missing code in callback")})
http.Error(w, "missing code", http.StatusBadRequest)
return
}
tok, err := pf.oauthCfg.Exchange(r.Context(), code,
oauth2.SetAuthURLParam("code_verifier", pf.pkce.Verifier),
)
if err != nil {
f.deliver(pf, flowOutcome{err: fmt.Errorf("token exchange: %w", err)})
http.Error(w, "token exchange failed", http.StatusBadGateway)
return
}
rawID, _ := tok.Extra("id_token").(string)
if rawID == "" {
f.deliver(pf, flowOutcome{err: errors.New("token response missing id_token")})
http.Error(w, "missing id_token", http.StatusBadGateway)
return
}
idTok, err := pf.verifier.Verify(r.Context(), rawID)
if err != nil {
f.deliver(pf, flowOutcome{err: fmt.Errorf("verify id_token: %w", err)})
http.Error(w, "id_token verification failed", http.StatusBadGateway)
return
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
_ = idTok.Claims(&claims)
res := FlowResult{
IDToken: rawID,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
IDExpiresAt: idTok.Expiry,
Issuer: f.cfg.Issuer,
Subject: claims.Sub,
Email: claims.Email,
Name: claims.Name,
}
// Authentik returns a refresh-token expiry as a number-of-seconds
// extra; fall back to "now + 30d" when missing so the field is
// always populated.
if v, ok := tok.Extra("refresh_expires_in").(float64); ok && v > 0 {
res.RefreshExpAt = time.Now().Add(time.Duration(v) * time.Second)
} else if res.RefreshToken != "" {
res.RefreshExpAt = time.Now().Add(30 * 24 * time.Hour)
}
f.deliver(pf, flowOutcome{res: res})
_, _ = w.Write(successPage(res))
}
func (f *Flow) deliver(pf *pendingFlow, out flowOutcome) {
pf.finishedAt = time.Now()
select {
case pf.result <- out:
default:
}
}
// Errors surfaced via the broker RPC layer.
var (
ErrNotConfigured = errors.New("authn: broker not configured for Authentik (see plan.md Open #11)")
ErrUnknownState = errors.New("authn: unknown or expired state")
ErrFlowExpired = errors.New("authn: flow expired before callback arrived")
)
func successPage(r FlowResult) []byte {
who := r.Name
if who == "" {
who = r.Subject
}
body := `<!doctype html><html><head><meta charset="utf-8"><title>sherlock</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:4em auto;padding:0 1em;color:#222}</style>
</head><body>
<h1>Logged in</h1>
<p>Signed in as <b>` + htmlEscape(who) + `</b>. You can close this tab and return to the terminal.</p>
</body></html>`
return []byte(body)
}
func failPage(code, desc string) []byte {
body := `<!doctype html><html><head><meta charset="utf-8"><title>sherlock</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:4em auto;padding:0 1em;color:#222}</style>
</head><body>
<h1>Login failed</h1>
<p><b>` + htmlEscape(code) + `</b>: ` + htmlEscape(desc) + `</p>
<p>Check the terminal for details.</p>
</body></html>`
return []byte(body)
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;")
return r.Replace(s)
}
-199
View File
@@ -1,199 +0,0 @@
package authn_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
)
// stubAuthentik is a minimal OIDC provider sufficient to drive Flow
// end-to-end: it implements discovery, an authorize endpoint that
// immediately redirects with a code, and a token endpoint that returns
// a signed id_token.
type stubAuthentik struct {
server *httptest.Server
signer jose.Signer
pubJWK jose.JSONWebKey
issuer string
clientID string
// State remembered between authorize and token calls.
expectedCodeChallenge string
}
func newStub(t *testing.T, clientID string) *stubAuthentik {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test"))
if err != nil {
t.Fatalf("signer: %v", err)
}
pub := jose.JSONWebKey{Key: &key.PublicKey, KeyID: "test", Algorithm: "ES256", Use: "sig"}
s := &stubAuthentik{signer: signer, pubJWK: pub, clientID: clientID}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": s.issuer,
"authorization_endpoint": s.issuer + "/auth",
"token_endpoint": s.issuer + "/token",
"jwks_uri": s.issuer + "/jwks",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"ES256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{pub}})
})
mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) {
// Capture code_challenge so we can demand the right verifier
// on the token call. Redirect to the redirect_uri with state+code.
s.expectedCodeChallenge = r.URL.Query().Get("code_challenge")
redirect := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
u, _ := url.Parse(redirect)
q := u.Query()
q.Set("code", "stub-code")
q.Set("state", state)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
verifier := r.PostForm.Get("code_verifier")
sum := sha256.Sum256([]byte(verifier))
got := base64.RawURLEncoding.EncodeToString(sum[:])
if got != s.expectedCodeChallenge {
http.Error(w, "verifier mismatch", http.StatusBadRequest)
return
}
now := time.Now()
claims := struct {
jwt.Claims
Email string `json:"email"`
Name string `json:"name"`
}{
Claims: jwt.Claims{
Issuer: s.issuer,
Subject: "stub-user",
Audience: jwt.Audience{s.clientID},
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
},
Email: "stub@example",
Name: "Stub User",
}
idToken, err := jwt.Signed(s.signer).Claims(claims).Serialize()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-1",
"token_type": "Bearer",
"refresh_token": "refresh-1",
"expires_in": 3600,
"refresh_expires_in": 30 * 24 * 3600,
"id_token": idToken,
})
})
s.server = httptest.NewServer(mux)
s.issuer = s.server.URL
t.Cleanup(s.server.Close)
return s
}
// ensure big.Int is "imported" (referenced) so future tweaks don't
// require touching imports.
var _ = big.NewInt
func TestFlow_EndToEnd(t *testing.T) {
stub := newStub(t, "sherlock-client")
flow := authn.NewFlow(authn.Config{
Issuer: stub.issuer,
ClientID: "sherlock-client",
Scopes: []string{"openid", "profile", "email"},
}, nil)
defer func() { _ = flow.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
start, err := flow.StartFlow(ctx)
if err != nil {
t.Fatalf("StartFlow: %v", err)
}
if start.State == "" || !strings.HasPrefix(start.AuthURL, stub.issuer+"/auth?") {
t.Fatalf("bad start: %+v", start)
}
// Simulate the browser: follow the redirect chain. We DON'T want
// the http.Client to follow into our loopback callback as part of
// the same client (it would try and the cb would 200), so just let
// the chain go.
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(start.AuthURL)
if err != nil {
t.Fatalf("GET auth: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode >= 400 {
t.Fatalf("auth chain ended at %s", resp.Status)
}
res, err := flow.AwaitCallback(ctx, start.State, 5*time.Second)
if err != nil {
t.Fatalf("AwaitCallback: %v", err)
}
if res.Subject != "stub-user" || res.Email != "stub@example" {
t.Fatalf("bad claims: %+v", res)
}
if res.IDToken == "" || res.RefreshToken != "refresh-1" {
t.Fatalf("bad tokens: %+v", res)
}
if time.Until(res.IDExpiresAt) < 30*time.Minute {
t.Fatalf("id token expiry too soon: %v", res.IDExpiresAt)
}
}
func TestFlow_NotConfigured(t *testing.T) {
flow := authn.NewFlow(authn.Config{}, nil)
defer func() { _ = flow.Close() }()
_, err := flow.StartFlow(context.Background())
if err == nil || !strings.Contains(err.Error(), "not configured") {
t.Fatalf("expected not-configured error, got %v", err)
}
}
// Sanity check that fmt is referenced by tests so an unused import
// linter doesn't complain after future edits.
var _ = fmt.Sprintf
+226
View File
@@ -0,0 +1,226 @@
package authn
import (
"context"
"crypto/tls"
"errors"
"fmt"
"html"
"net"
"net/http"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
)
// ErrNotConfigured is returned by Login when Config.Configured() is
// false. The CLI surfaces it with the env vars to set.
var ErrNotConfigured = errors.New("authn: Authentik issuer/client ID not configured")
// Result is everything Login returns on success.
type Result struct {
Tokens keyring.TokenSet
}
// LoginOptions tunes Login for tests. Production code passes zero.
type LoginOptions struct {
// Discoverer overrides OIDC well-known discovery. Default is
// DefaultDiscoverer{}.
Discoverer Discoverer
// HTTPClient is plumbed into oauth2.HTTPClient context. Default
// is &http.Client{} with TLS using the system cert pool.
HTTPClient *http.Client
// OnAuthURL is called once with the URL the operator must visit
// (after the listener has bound, before blocking on the callback).
// Tests set this to drive the flow programmatically; the CLI
// opens it in the browser.
OnAuthURL func(url string)
}
// Login runs the full Authentik PKCE flow synchronously: bind a
// loopback listener, generate state + PKCE, build the auth URL and
// hand it to OnAuthURL, then block until the browser redirects to
// /callback (or ctx is cancelled). On success it verifies the ID
// token and returns the populated TokenSet.
//
// Login is safe to call from multiple processes only one at a time —
// the loopback port is a global resource. A second concurrent Login
// will fail with EADDRINUSE; that's the right behavior (the operator
// is doing two `sherlock login` invocations).
func Login(ctx context.Context, cfg Config, opts LoginOptions) (Result, error) {
if !cfg.Configured() {
return Result{}, ErrNotConfigured
}
if opts.Discoverer == nil {
opts.Discoverer = DefaultDiscoverer{}
}
if opts.HTTPClient == nil {
opts.HTTPClient = &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}}
}
if opts.OnAuthURL == nil {
opts.OnAuthURL = func(string) {}
}
ln, err := net.Listen("tcp", cfg.loopbackAddr())
if err != nil {
return Result{}, fmt.Errorf("authn: listen on %s: %w", cfg.loopbackAddr(), err)
}
defer func() { _ = ln.Close() }()
// The redirect URL must match what we registered with Authentik
// exactly. We use the listener's reported address so tests can
// bind :0 — production always binds the canonical LoopbackAddr.
redirectURL := "http://" + ln.Addr().String() + "/callback"
ctx = oidc.ClientContext(ctx, opts.HTTPClient)
provider, err := opts.Discoverer.Discover(ctx, cfg.Issuer)
if err != nil {
return Result{}, err
}
pkce, err := NewPKCE()
if err != nil {
return Result{}, err
}
state, err := newState()
if err != nil {
return Result{}, err
}
oauthCfg := &oauth2.Config{
ClientID: cfg.ClientID,
Endpoint: provider.Endpoint(),
RedirectURL: redirectURL,
Scopes: cfg.Scopes,
}
authURL := oauthCfg.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", pkce.Challenge),
oauth2.SetAuthURLParam("code_challenge_method", pkce.Method),
)
cb := make(chan callbackOutcome, 1)
srv := &http.Server{
Handler: callbackHandler(state, cb),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
// Serve returns once Shutdown is called from below.
_ = srv.Serve(ln)
}()
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
// Open the browser asynchronously so callers can do blocking work
// here (e.g. test stubs that drive the redirect themselves).
go opts.OnAuthURL(authURL)
select {
case <-ctx.Done():
return Result{}, ctx.Err()
case out := <-cb:
if out.err != nil {
return Result{}, out.err
}
return exchangeAndVerify(ctx, cfg, provider, oauthCfg, pkce, out.code)
}
}
type callbackOutcome struct {
code string
err error
}
func callbackHandler(expectedState string, out chan<- callbackOutcome) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
desc := q.Get("error_description")
renderHTML(w, "Login failed", fmt.Sprintf("%s: %s", e, desc), http.StatusBadRequest)
out <- callbackOutcome{err: fmt.Errorf("authn: callback error: %s: %s", e, desc)}
return
}
gotState := q.Get("state")
if gotState != expectedState {
renderHTML(w, "Login failed", "State mismatch (possible CSRF).", http.StatusBadRequest)
out <- callbackOutcome{err: errors.New("authn: callback state mismatch")}
return
}
code := q.Get("code")
if code == "" {
renderHTML(w, "Login failed", "Authorization server returned no code.", http.StatusBadRequest)
out <- callbackOutcome{err: errors.New("authn: callback missing code")}
return
}
renderHTML(w, "Logged in", "You may close this tab and return to your terminal.", http.StatusOK)
out <- callbackOutcome{code: code}
})
}
func exchangeAndVerify(ctx context.Context, cfg Config, provider *oidc.Provider, oauthCfg *oauth2.Config, pkce PKCE, code string) (Result, error) {
tok, err := oauthCfg.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", pkce.Verifier),
)
if err != nil {
return Result{}, fmt.Errorf("authn: token exchange: %w", err)
}
rawID, _ := tok.Extra("id_token").(string)
if rawID == "" {
return Result{}, errors.New("authn: token response missing id_token")
}
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
idTok, err := verifier.Verify(ctx, rawID)
if err != nil {
return Result{}, fmt.Errorf("authn: id_token verify: %w", err)
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idTok.Claims(&claims); err != nil {
return Result{}, fmt.Errorf("authn: id_token claims: %w", err)
}
ts := keyring.TokenSet{
IDToken: rawID,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
IDExpiresAt: idTok.Expiry,
RefreshExpAt: refreshExpiry(tok),
Issuer: cfg.Issuer,
ClientID: cfg.ClientID,
Scopes: cfg.Scopes,
Subject: claims.Sub,
Email: claims.Email,
Name: claims.Name,
}
return Result{Tokens: ts}, nil
}
// refreshExpiry pulls Authentik's `refresh_expires_in` (an extension
// to RFC 6749). If absent we conservatively assume 30 days, matching
// Authentik's default.
func refreshExpiry(tok *oauth2.Token) time.Time {
if v, ok := tok.Extra("refresh_expires_in").(float64); ok && v > 0 {
return time.Now().Add(time.Duration(v) * time.Second)
}
return time.Now().Add(30 * 24 * time.Hour)
}
func renderHTML(w http.ResponseWriter, title, body string, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
const tmpl = `<!doctype html><html><head><meta charset="utf-8"><title>%s — sherlock</title>` +
`<style>body{font-family:system-ui,sans-serif;max-width:30em;margin:6em auto;color:#111}` +
`h1{font-size:1.4em}p{color:#444}</style></head>` +
`<body><h1>%s</h1><p>%s</p></body></html>`
_, _ = fmt.Fprintf(w, tmpl, html.EscapeString(title), html.EscapeString(title), html.EscapeString(body))
}
+331
View File
@@ -0,0 +1,331 @@
package authn
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
)
// stubAuthentik signs ES256 ID tokens and returns them through a real
// httptest.Server, exercising the full Login path end-to-end without
// touching a live Authentik instance.
type stubAuthentik struct {
srv *httptest.Server
signer jose.Signer
pubJWK jose.JSONWebKey
verifyCalled bool
mu sync.Mutex
codeToPKCE map[string]string
}
func newStubAuthentik(t *testing.T) *stubAuthentik {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: priv},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test"))
if err != nil {
t.Fatal(err)
}
stub := &stubAuthentik{
signer: signer,
pubJWK: jose.JSONWebKey{
Key: priv.Public(),
KeyID: "test",
Algorithm: "ES256",
Use: "sig",
},
codeToPKCE: map[string]string{},
}
mux := http.NewServeMux()
stub.srv = httptest.NewServer(mux)
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": stub.srv.URL,
"authorization_endpoint": stub.srv.URL + "/authorize",
"token_endpoint": stub.srv.URL + "/token",
"jwks_uri": stub.srv.URL + "/jwks",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"ES256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{stub.pubJWK}})
})
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
stub.mu.Lock()
stub.codeToPKCE["code-"+q.Get("state")] = q.Get("code_challenge")
stub.mu.Unlock()
// Redirect to the loopback redirect URI with the code+state.
ru, _ := url.Parse(q.Get("redirect_uri"))
rq := ru.Query()
rq.Set("code", "code-"+q.Get("state"))
rq.Set("state", q.Get("state"))
ru.RawQuery = rq.Encode()
http.Redirect(w, r, ru.String(), http.StatusFound)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
code := r.Form.Get("code")
verifier := r.Form.Get("code_verifier")
stub.mu.Lock()
challenge, ok := stub.codeToPKCE[code]
stub.mu.Unlock()
if !ok {
http.Error(w, "unknown code", http.StatusBadRequest)
return
}
sum := sha256.Sum256([]byte(verifier))
want := base64.RawURLEncoding.EncodeToString(sum[:])
if want != challenge {
http.Error(w, "bad code_verifier", http.StatusBadRequest)
return
}
stub.verifyCalled = true
// Public PKCE clients don't always send client_id in the body
// (oauth2 prefers Basic-Auth when AuthStyle defaults to header).
// Pull it from Authorization or fall back to the form/test value.
clientID := r.Form.Get("client_id")
if clientID == "" {
if user, _, ok := r.BasicAuth(); ok {
clientID = user
}
}
if clientID == "" {
clientID = "test-client"
}
idToken := stub.signIDToken(t, clientID, map[string]any{
"sub": "user-1",
"email": "ops@example.com",
"name": "Test Operator",
})
resp := map[string]any{
"access_token": "at-1",
"refresh_token": "rt-1",
"id_token": idToken,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_expires_in": 86400,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
t.Cleanup(stub.srv.Close)
return stub
}
func (s *stubAuthentik) signIDToken(t *testing.T, audience string, claims map[string]any) string {
t.Helper()
now := time.Now()
all := map[string]any{
"iss": s.srv.URL,
"aud": audience,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
for k, v := range claims {
all[k] = v
}
tok, err := jwt.Signed(s.signer).Claims(all).Serialize()
if err != nil {
t.Fatal(err)
}
return tok
}
func TestLogin_HappyPath(t *testing.T) {
stub := newStubAuthentik(t)
cfg := Config{
Issuer: stub.srv.URL,
ClientID: "test-client",
Scopes: []string{"openid", "profile", "email"},
LoopbackAddrOverride: "127.0.0.1:0",
}
// The browser-open hook drives the flow programmatically.
var browserErr error
opts := LoginOptions{
OnAuthURL: func(u string) {
resp, err := (&http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}).Get(u)
if err != nil {
browserErr = err
return
}
defer func() { _ = resp.Body.Close() }()
loc := resp.Header.Get("Location")
if loc == "" {
browserErr = fmt.Errorf("expected redirect, got %d", resp.StatusCode)
return
}
// Follow the redirect into the loopback listener.
cb, err := http.Get(loc)
if err != nil {
browserErr = err
return
}
_, _ = io.Copy(io.Discard, cb.Body)
_ = cb.Body.Close()
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := Login(ctx, cfg, opts)
if err != nil {
t.Fatalf("Login: %v", err)
}
if browserErr != nil {
t.Fatalf("browser stub: %v", browserErr)
}
if res.Tokens.IDToken == "" || res.Tokens.RefreshToken == "" {
t.Fatalf("missing tokens: %+v", res.Tokens)
}
if res.Tokens.Subject != "user-1" || res.Tokens.Email != "ops@example.com" {
t.Fatalf("claims wrong: %+v", res.Tokens)
}
if res.Tokens.ClientID != "test-client" {
t.Fatalf("ClientID not persisted: %q", res.Tokens.ClientID)
}
if !strings.HasPrefix(res.Tokens.Issuer, "http://127.0.0.1:") {
t.Fatalf("Issuer = %q", res.Tokens.Issuer)
}
}
func TestLogin_NotConfigured(t *testing.T) {
_, err := Login(context.Background(), Config{}, LoginOptions{})
if err != ErrNotConfigured {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
}
func TestLogin_ContextCancel(t *testing.T) {
stub := newStubAuthentik(t)
cfg := Config{
Issuer: stub.srv.URL,
ClientID: "test-client",
Scopes: []string{"openid"},
LoopbackAddrOverride: "127.0.0.1:0",
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
_, err := Login(ctx, cfg, LoginOptions{OnAuthURL: func(string) {}})
if err == nil {
t.Fatal("expected error on cancel")
}
}
// Sanity check that the verifier built off the stub provider works
// outside Login too — guards against accidental client-ID drift.
func TestIDTokenVerifies_AgainstStub(t *testing.T) {
stub := newStubAuthentik(t)
provider, err := oidc.NewProvider(context.Background(), stub.srv.URL)
if err != nil {
t.Fatal(err)
}
raw := stub.signIDToken(t, "client-x", map[string]any{"sub": "u"})
if _, err := provider.Verifier(&oidc.Config{ClientID: "client-x"}).Verify(context.Background(), raw); err != nil {
t.Fatal(err)
}
}
func TestEnsureFresh_FastPath(t *testing.T) {
store := newMemStore()
store.tokens = keyring.TokenSet{
IDToken: "ok",
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: "x",
ClientID: "y",
}
ts, err := EnsureFresh(context.Background(), store, EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
if err != nil {
t.Fatalf("EnsureFresh: %v", err)
}
if ts.IDToken != "ok" {
t.Fatal("fast path should return current token")
}
}
func TestEnsureFresh_NoTokens(t *testing.T) {
store := newMemStore()
_, err := EnsureFresh(context.Background(), store, EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
if err != keyring.ErrNoTokens {
t.Fatalf("err = %v, want ErrNoTokens", err)
}
}
func TestEnsureFresh_NeedsRefreshButNoRefreshToken(t *testing.T) {
store := newMemStore()
store.tokens = keyring.TokenSet{
IDToken: "stale",
IDExpiresAt: time.Now().Add(-time.Minute),
Issuer: "x",
ClientID: "y",
}
_, err := EnsureFresh(context.Background(), store, EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
if err != ErrNoRefreshToken {
t.Fatalf("err = %v, want ErrNoRefreshToken", err)
}
}
type memStore struct {
mu sync.Mutex
tokens keyring.TokenSet
cleared bool
}
func newMemStore() *memStore { return &memStore{} }
func (m *memStore) GetAuthentikTokens() (keyring.TokenSet, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.tokens.Empty() {
return keyring.TokenSet{}, keyring.ErrNoTokens
}
return m.tokens, nil
}
func (m *memStore) SetAuthentikTokens(ts keyring.TokenSet) error {
m.mu.Lock()
defer m.mu.Unlock()
m.tokens = ts
return nil
}
func (m *memStore) ClearAuthentikTokens() error {
m.mu.Lock()
defer m.mu.Unlock()
m.tokens = keyring.TokenSet{}
m.cleared = true
return nil
}
+108 -66
View File
@@ -1,110 +1,152 @@
//go:build unix
package authn
import (
"context"
"errors"
"fmt"
"sync"
"os"
"path/filepath"
"syscall"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/sync/singleflight"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
)
// RefreshSkew is how far ahead of id_token expiry we trigger a refresh.
// RefreshSkew is how much headroom EnsureFresh leaves before the ID
// token actually expires. Refreshing 30s ahead of expiry avoids
// shipping a token that will be rejected by clock-skewed resource
// servers.
const RefreshSkew = 30 * time.Second
// Refresher refreshes an Authentik TokenSet. Construct one per Flow
// (it caches the discovered provider via the Flow's discoverer).
type Refresher struct {
cfg Config
discoverer Discoverer
// ErrNoRefreshToken is returned by EnsureFresh when the stored
// TokenSet has no refresh token to swap.
var ErrNoRefreshToken = errors.New("authn: no refresh token stored")
mu sync.Mutex
endpoint oauth2.Endpoint
have bool
group singleflight.Group
// EnsureFreshOptions tunes EnsureFresh for tests.
type EnsureFreshOptions struct {
// LockPath is the absolute path to the cross-process flock file.
// Required.
LockPath string
// Discoverer overrides OIDC discovery for the refresh round-trip.
Discoverer Discoverer
// Clock is injected for time-warp tests.
Clock func() time.Time
}
// NewRefresher builds a Refresher. Pass the same Config the Flow uses.
func NewRefresher(cfg Config, d Discoverer) *Refresher {
if d == nil {
d = DefaultDiscoverer{}
// EnsureFresh returns the stored TokenSet, refreshing it via Authentik
// if the ID token has < RefreshSkew left. Cross-process safe: takes
// an exclusive flock on opts.LockPath, re-reads the keyring after
// acquiring the lock, and skips the refresh if a concurrent process
// already rotated the tokens.
//
// The lockfile is created with mode 0600 and never removed — its
// only role is to anchor the flock; multiple sherlock invocations
// across an operator's lifetime share it.
func EnsureFresh(ctx context.Context, store keyring.Store, opts EnsureFreshOptions) (keyring.TokenSet, error) {
if opts.LockPath == "" {
return keyring.TokenSet{}, errors.New("authn: EnsureFresh requires LockPath")
}
if opts.Discoverer == nil {
opts.Discoverer = DefaultDiscoverer{}
}
if opts.Clock == nil {
opts.Clock = time.Now
}
return &Refresher{cfg: cfg, discoverer: d}
}
// EnsureFresh returns ts unchanged if its IDToken is good for at least
// RefreshSkew. Otherwise it performs a single-flight refresh using the
// stored refresh token and returns the new TokenSet.
func (r *Refresher) EnsureFresh(ctx context.Context, ts keyring.TokenSet) (keyring.TokenSet, error) {
if ts.IDToken != "" && time.Until(ts.IDExpiresAt) > RefreshSkew {
ts, err := store.GetAuthentikTokens()
if err != nil {
return keyring.TokenSet{}, err
}
if ts.Empty() {
return ts, keyring.ErrNoTokens
}
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
return ts, nil
}
// Slow path: take the lock, double-check, refresh under the lock
// so concurrent processes serialise.
if err := os.MkdirAll(filepath.Dir(opts.LockPath), 0o700); err != nil {
return ts, fmt.Errorf("authn: mkdir lockdir: %w", err)
}
f, err := os.OpenFile(opts.LockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return ts, fmt.Errorf("authn: open lockfile: %w", err)
}
defer func() { _ = f.Close() }()
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
return ts, fmt.Errorf("authn: flock lockfile: %w", err)
}
defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()
// Re-read after acquiring the lock. A concurrent process may have
// refreshed for us while we were waiting on flock.
ts, err = store.GetAuthentikTokens()
if err != nil {
return ts, err
}
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
return ts, nil
}
if ts.RefreshToken == "" {
return ts, ErrNoRefreshToken
}
v, err, _ := r.group.Do("refresh", func() (any, error) {
return r.refresh(ctx, ts)
})
fresh, err := refreshOnce(ctx, ts, opts.Discoverer)
if err != nil {
return ts, err
}
return v.(keyring.TokenSet), nil
if err := store.SetAuthentikTokens(fresh); err != nil {
return ts, fmt.Errorf("authn: persist refreshed tokens: %w", err)
}
return fresh, nil
}
func (r *Refresher) refresh(ctx context.Context, ts keyring.TokenSet) (keyring.TokenSet, error) {
if !r.cfg.Configured() {
return ts, ErrNotConfigured
func refreshOnce(ctx context.Context, ts keyring.TokenSet, disc Discoverer) (keyring.TokenSet, error) {
if ts.Issuer == "" || ts.ClientID == "" {
return ts, errors.New("authn: stored tokens missing issuer or client_id (re-login)")
}
if err := r.ensureEndpoint(ctx); err != nil {
provider, err := disc.Discover(ctx, ts.Issuer)
if err != nil {
return ts, err
}
src := (&oauth2.Config{
ClientID: r.cfg.ClientID,
Endpoint: r.endpoint,
}).TokenSource(ctx, &oauth2.Token{RefreshToken: ts.RefreshToken})
cfg := &oauth2.Config{
ClientID: ts.ClientID,
Endpoint: provider.Endpoint(),
Scopes: ts.Scopes,
}
src := cfg.TokenSource(ctx, &oauth2.Token{
AccessToken: ts.AccessToken,
RefreshToken: ts.RefreshToken,
// Force the source to refresh by claiming we expired in the past.
Expiry: time.Now().Add(-1 * time.Minute),
})
tok, err := src.Token()
if err != nil {
return ts, fmt.Errorf("authn: refresh: %w", err)
}
rawID, _ := tok.Extra("id_token").(string)
if rawID == "" {
// Some IdPs omit id_token on refresh; keep the old one if so.
rawID = ts.IDToken
}
verifier := provider.Verifier(&oidc.Config{ClientID: ts.ClientID})
idTok, err := verifier.Verify(ctx, rawID)
if err != nil {
return ts, fmt.Errorf("authn: refreshed id_token verify: %w", err)
}
out := ts
if rawID != "" {
out.IDToken = rawID
}
if tok.AccessToken != "" {
out.AccessToken = tok.AccessToken
}
out.IDToken = rawID
out.AccessToken = tok.AccessToken
if tok.RefreshToken != "" {
out.RefreshToken = tok.RefreshToken
}
out.IDExpiresAt = tok.Expiry
if v, ok := tok.Extra("refresh_expires_in").(float64); ok && v > 0 {
out.RefreshExpAt = time.Now().Add(time.Duration(v) * time.Second)
}
out.IDExpiresAt = idTok.Expiry
out.RefreshExpAt = refreshExpiry(tok)
return out, nil
}
func (r *Refresher) ensureEndpoint(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.have {
return nil
}
p, err := r.discoverer.Discover(ctx, r.cfg.Issuer)
if err != nil {
return err
}
r.endpoint = p.Endpoint()
r.have = true
return nil
}
// ErrNoRefreshToken is returned when a refresh is required but the
// stored TokenSet has no refresh_token.
var ErrNoRefreshToken = errors.New("authn: no refresh token; re-login required")
-9
View File
@@ -1,9 +0,0 @@
// Package broker implements the sherlock per-user daemon: process
// lifecycle (PID file + flock + idle timeout), JSON-over-newline RPC
// dispatch, and the EnsureRunning helper that the wrapper CLI uses to
// dial-or-fork the broker.
//
// Decision #10 (Phase 1): the broker runs as a forked, setsid-detached
// child process — no systemd unit. A second invocation will be refused
// by the PID-file flock.
package broker
-70
View File
@@ -1,70 +0,0 @@
//go:build unix
package broker
import (
"errors"
"fmt"
"os"
"strconv"
"syscall"
)
// ErrAlreadyRunning is returned by AcquirePidLock when another broker
// holds the lock.
var ErrAlreadyRunning = errors.New("broker: another instance is running")
// PidLock is a file-based singleton guard. The file at Path is opened
// for writing, flock(LOCK_EX|LOCK_NB) is taken, and the current PID is
// written. Releasing closes the fd (and the kernel drops the lock) and
// removes the file.
type PidLock struct {
Path string
f *os.File
}
// AcquirePidLock obtains an exclusive lock on path or returns
// ErrAlreadyRunning if another broker holds it. The directory must
// already exist.
func AcquirePidLock(path string) (*PidLock, error) {
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("broker: open pid file %s: %w", path, err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, ErrAlreadyRunning
}
return nil, fmt.Errorf("broker: flock pid file: %w", err)
}
if err := f.Truncate(0); err != nil {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
return nil, fmt.Errorf("broker: truncate pid file: %w", err)
}
if _, err := f.WriteString(strconv.Itoa(os.Getpid()) + "\n"); err != nil {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
return nil, fmt.Errorf("broker: write pid: %w", err)
}
return &PidLock{Path: path, f: f}, nil
}
// Release drops the lock and removes the PID file. Safe to call twice.
func (l *PidLock) Release() error {
if l == nil || l.f == nil {
return nil
}
_ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN)
cerr := l.f.Close()
rerr := os.Remove(l.Path)
l.f = nil
if cerr != nil {
return cerr
}
if rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
return rerr
}
return nil
}
-33
View File
@@ -1,33 +0,0 @@
//go:build unix
package broker
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestPidLock_Singleton(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.pid")
first, err := AcquirePidLock(path)
if err != nil {
t.Fatalf("first AcquirePidLock: %v", err)
}
if _, err := AcquirePidLock(path); !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("expected ErrAlreadyRunning on second acquire, got %v", err)
}
if err := first.Release(); err != nil {
t.Fatalf("Release: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("pid file should be removed, stat err = %v", err)
}
// After release, a fresh acquire must succeed.
second, err := AcquirePidLock(path)
if err != nil {
t.Fatalf("third AcquirePidLock: %v", err)
}
_ = second.Release()
}
-226
View File
@@ -1,226 +0,0 @@
package broker
import (
"context"
"encoding/json"
"errors"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/rpc"
)
// Methods bundles the broker's dependencies and exposes a MethodTable
// for socket dispatch. The fields are exported so cmd/sherlock-broker
// can construct one with real implementations and tests can swap in
// fakes (the *keyring.Store interface is the obvious seam).
type Methods struct {
Store keyring.Store
Flow *authn.Flow
Refresher *authn.Refresher
Cfg authn.Config
// ShutdownFn is invoked by the "shutdown" RPC. Wired by the daemon
// to cancel the root context.
ShutdownFn func()
// AgentNames feeds the status response so the CLI can show which
// profiles are available without re-loading them.
AgentNames func() []string
}
// MethodTable returns the map suitable for NewService.
func (m *Methods) MethodTable() map[string]MethodHandler {
return map[string]MethodHandler{
rpc.MethodStatus: m.handleStatus,
rpc.MethodWhoami: m.handleWhoami,
rpc.MethodLoginStart: m.handleLoginStart,
rpc.MethodLoginWait: m.handleLoginWait,
rpc.MethodLogout: m.handleLogout,
rpc.MethodGetIDToken: m.handleGetIDToken,
rpc.MethodShutdown: m.handleShutdown,
}
}
// loadTokens reads the keyring once and caches the result in memory.
// We re-read on every status to keep things simple — the keyring is
// the source of truth.
func (m *Methods) currentTokens() (keyring.TokenSet, bool, error) {
ts, err := m.Store.GetAuthentikTokens()
if errors.Is(err, keyring.ErrNoTokens) {
return keyring.TokenSet{}, false, nil
}
if err != nil {
return keyring.TokenSet{}, false, err
}
return ts, !ts.Empty(), nil
}
// StatusResult is what the status RPC returns.
type StatusResult struct {
LoggedIn bool `json:"logged_in"`
Subject string `json:"sub,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
IDExpiresAt *time.Time `json:"id_expires_at,omitempty"`
RefreshExpAt *time.Time `json:"refresh_expires_at,omitempty"`
Issuer string `json:"issuer,omitempty"`
AuthnConfigured bool `json:"authn_configured"`
Agents []string `json:"agents"`
}
func ptrTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
tt := t
return &tt
}
func (m *Methods) handleStatus(_ context.Context, _ json.RawMessage) (any, *rpc.Error) {
ts, ok, err := m.currentTokens()
if err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "read keyring: %v", err)
}
out := StatusResult{
LoggedIn: ok,
AuthnConfigured: m.Cfg.Configured(),
}
if m.AgentNames != nil {
out.Agents = m.AgentNames()
}
if ok {
out.Subject = ts.Subject
out.Name = ts.Name
out.Email = ts.Email
out.Issuer = ts.Issuer
out.IDExpiresAt = ptrTime(ts.IDExpiresAt)
out.RefreshExpAt = ptrTime(ts.RefreshExpAt)
}
return out, nil
}
// WhoamiResult is what the whoami RPC returns.
type WhoamiResult struct {
Subject string `json:"sub"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
}
func (m *Methods) handleWhoami(_ context.Context, _ json.RawMessage) (any, *rpc.Error) {
ts, ok, err := m.currentTokens()
if err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "read keyring: %v", err)
}
if !ok {
return nil, rpc.NewError(rpc.CodeNotLoggedIn, "not logged in")
}
return WhoamiResult{Subject: ts.Subject, Name: ts.Name, Email: ts.Email}, nil
}
// LoginStartResult is what the login_start RPC returns.
type LoginStartResult struct {
AuthURL string `json:"auth_url"`
State string `json:"state"`
}
func (m *Methods) handleLoginStart(ctx context.Context, _ json.RawMessage) (any, *rpc.Error) {
if !m.Cfg.Configured() {
return nil, rpc.NewError(rpc.CodeNotConfigured,
"broker is not configured for Authentik yet; see plan.md Open #11 — set SHERLOCK_AUTHENTIK_ISSUER and SHERLOCK_AUTHENTIK_CLIENT_ID")
}
res, err := m.Flow.StartFlow(ctx)
if err != nil {
if errors.Is(err, authn.ErrNotConfigured) {
return nil, rpc.NewError(rpc.CodeNotConfigured, err.Error())
}
return nil, rpc.Errorf(rpc.CodeInternal, "start flow: %v", err)
}
return LoginStartResult{AuthURL: res.AuthURL, State: res.State}, nil
}
// LoginWaitParams are accepted by the login_wait RPC.
type LoginWaitParams struct {
State string `json:"state"`
TimeoutSeconds int `json:"timeout_seconds"`
}
// LoginWaitResult is what login_wait returns.
type LoginWaitResult struct {
OK bool `json:"ok"`
Subject string `json:"sub,omitempty"`
}
func (m *Methods) handleLoginWait(ctx context.Context, raw json.RawMessage) (any, *rpc.Error) {
var p LoginWaitParams
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
return nil, rpc.Errorf(rpc.CodeBadRequest, "decode params: %v", err)
}
}
if p.State == "" {
return nil, rpc.NewError(rpc.CodeBadRequest, "state is required")
}
timeout := time.Duration(p.TimeoutSeconds) * time.Second
res, err := m.Flow.AwaitCallback(ctx, p.State, timeout)
if err != nil {
if errors.Is(err, authn.ErrFlowExpired) {
return nil, rpc.NewError(rpc.CodeFlowExpired, err.Error())
}
if errors.Is(err, authn.ErrUnknownState) {
return nil, rpc.NewError(rpc.CodeBadRequest, err.Error())
}
return nil, rpc.Errorf(rpc.CodeInternal, "await callback: %v", err)
}
if err := m.Store.SetAuthentikTokens(res.ToTokenSet()); err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "persist tokens: %v", err)
}
return LoginWaitResult{OK: true, Subject: res.Subject}, nil
}
func (m *Methods) handleLogout(_ context.Context, _ json.RawMessage) (any, *rpc.Error) {
if err := m.Store.ClearAuthentikTokens(); err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "clear keyring: %v", err)
}
return struct{}{}, nil
}
// GetIDTokenResult is what get_id_token returns.
type GetIDTokenResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
func (m *Methods) handleGetIDToken(ctx context.Context, _ json.RawMessage) (any, *rpc.Error) {
ts, ok, err := m.currentTokens()
if err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "read keyring: %v", err)
}
if !ok {
return nil, rpc.NewError(rpc.CodeNotLoggedIn, "not logged in")
}
if m.Refresher != nil {
fresh, err := m.Refresher.EnsureFresh(ctx, ts)
if err != nil {
if errors.Is(err, authn.ErrNoRefreshToken) {
return nil, rpc.NewError(rpc.CodeNotLoggedIn, "refresh token missing — re-login required")
}
return nil, rpc.Errorf(rpc.CodeInternal, "refresh: %v", err)
}
if fresh.IDToken != ts.IDToken {
if err := m.Store.SetAuthentikTokens(fresh); err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "persist refreshed tokens: %v", err)
}
ts = fresh
}
}
return GetIDTokenResult{Token: ts.IDToken, ExpiresAt: ts.IDExpiresAt}, nil
}
func (m *Methods) handleShutdown(_ context.Context, _ json.RawMessage) (any, *rpc.Error) {
if m.ShutdownFn != nil {
go m.ShutdownFn() // run async so we can return a response first
}
return struct{}{}, nil
}
-110
View File
@@ -1,110 +0,0 @@
package broker
import (
"context"
"encoding/json"
"testing"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/rpc"
)
func newMethods(t *testing.T) *Methods {
t.Helper()
return &Methods{
Store: fakekeyring.New(),
Cfg: authn.Config{},
AgentNames: func() []string { return []string{"copilot"} },
}
}
func TestStatus_NotLoggedIn(t *testing.T) {
m := newMethods(t)
res, rpcErr := m.handleStatus(context.Background(), nil)
if rpcErr != nil {
t.Fatalf("rpc err: %v", rpcErr)
}
s := res.(StatusResult)
if s.LoggedIn {
t.Fatal("expected not logged in")
}
if s.AuthnConfigured {
t.Fatal("expected not configured")
}
if len(s.Agents) != 1 || s.Agents[0] != "copilot" {
t.Fatalf("agents = %v", s.Agents)
}
}
func TestStatus_LoggedIn(t *testing.T) {
m := newMethods(t)
exp := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
if err := m.Store.SetAuthentikTokens(keyring.TokenSet{
IDToken: "id", Subject: "alice", Email: "a@e", Name: "Alice",
IDExpiresAt: exp, Issuer: "https://id.example/",
}); err != nil {
t.Fatal(err)
}
res, rpcErr := m.handleStatus(context.Background(), nil)
if rpcErr != nil {
t.Fatalf("rpc err: %v", rpcErr)
}
s := res.(StatusResult)
if !s.LoggedIn || s.Subject != "alice" {
t.Fatalf("got %+v", s)
}
if s.IDExpiresAt == nil || !s.IDExpiresAt.Equal(exp) {
t.Fatalf("expiry: got %v want %v", s.IDExpiresAt, exp)
}
}
func TestWhoami_NotLoggedIn(t *testing.T) {
m := newMethods(t)
_, rpcErr := m.handleWhoami(context.Background(), nil)
if rpcErr == nil || rpcErr.Code != rpc.CodeNotLoggedIn {
t.Fatalf("want not_logged_in, got %+v", rpcErr)
}
}
func TestLoginStart_NotConfigured(t *testing.T) {
m := newMethods(t)
_, rpcErr := m.handleLoginStart(context.Background(), nil)
if rpcErr == nil || rpcErr.Code != rpc.CodeNotConfigured {
t.Fatalf("want not_configured, got %+v", rpcErr)
}
}
func TestLogout_Idempotent(t *testing.T) {
m := newMethods(t)
if _, err := m.handleLogout(context.Background(), nil); err != nil {
t.Fatalf("logout1: %v", err)
}
if _, err := m.handleLogout(context.Background(), nil); err != nil {
t.Fatalf("logout2: %v", err)
}
}
func TestShutdown_InvokesFn(t *testing.T) {
done := make(chan struct{})
m := newMethods(t)
m.ShutdownFn = func() { close(done) }
if _, err := m.handleShutdown(context.Background(), nil); err != nil {
t.Fatalf("shutdown: %v", err)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("ShutdownFn not invoked")
}
}
func TestLoginWait_BadParams(t *testing.T) {
m := newMethods(t)
_, rpcErr := m.handleLoginWait(context.Background(), json.RawMessage(`{}`))
if rpcErr == nil || rpcErr.Code != rpc.CodeBadRequest {
t.Fatalf("want bad_request, got %+v", rpcErr)
}
}
-68
View File
@@ -1,68 +0,0 @@
package broker
import (
"context"
"encoding/json"
"sync"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/rpc"
)
// MethodHandler is the per-method signature used by Service. Handlers
// receive the raw params (so each can unmarshal into its own typed
// struct) and return either a marshallable result or an *rpc.Error.
type MethodHandler func(ctx context.Context, params json.RawMessage) (any, *rpc.Error)
// Service is the broker's method table. It is the socket.Handler used
// by socket.Server. Tracks last-activity for the idle-timeout watcher
// in cmd/sherlock-broker.
type Service struct {
methods map[string]MethodHandler
mu sync.RWMutex
lastActivity time.Time
}
// NewService builds a Service from the provided method table. The
// reserved method "shutdown" is wired separately by the caller (it
// needs to cancel the broker's root context).
func NewService(methods map[string]MethodHandler) *Service {
cp := make(map[string]MethodHandler, len(methods))
for k, v := range methods {
cp[k] = v
}
return &Service{
methods: cp,
lastActivity: time.Now(),
}
}
// LastActivity returns the timestamp of the most recent request.
func (s *Service) LastActivity() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.lastActivity
}
// Handle is the socket.Handler implementation. It looks up the method,
// invokes it, and packs the result.
func (s *Service) Handle(ctx context.Context, req rpc.Request) rpc.Response {
s.mu.Lock()
s.lastActivity = time.Now()
s.mu.Unlock()
h, ok := s.methods[req.Method]
if !ok {
return rpc.Response{Error: rpc.Errorf(rpc.CodeUnknownMethod, "unknown method %q", req.Method)}
}
result, rpcErr := h(ctx, req.Params)
if rpcErr != nil {
return rpc.Response{Error: rpcErr}
}
raw, err := rpc.MarshalResult(result)
if err != nil {
return rpc.Response{Error: rpc.Errorf(rpc.CodeInternal, "marshal result: %v", err)}
}
return rpc.Response{Result: raw}
}
-58
View File
@@ -1,58 +0,0 @@
package broker
import (
"context"
"encoding/json"
"testing"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/rpc"
)
func TestService_UnknownMethod(t *testing.T) {
s := NewService(nil)
resp := s.Handle(context.Background(), rpc.Request{Method: "nope"})
if resp.Error == nil || resp.Error.Code != rpc.CodeUnknownMethod {
t.Fatalf("expected unknown_method, got %+v", resp.Error)
}
}
func TestService_DispatchAndActivity(t *testing.T) {
calls := 0
s := NewService(map[string]MethodHandler{
"ping": func(ctx context.Context, params json.RawMessage) (any, *rpc.Error) {
calls++
return map[string]string{"ok": "true"}, nil
},
})
before := s.LastActivity()
resp := s.Handle(context.Background(), rpc.Request{Method: "ping"})
if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error)
}
if calls != 1 {
t.Fatalf("handler not called")
}
if !s.LastActivity().After(before) && s.LastActivity().Equal(before) {
// Equal is OK if clock resolution is coarse; just sanity-check that it didn't go backwards.
t.Fatalf("LastActivity moved backwards: before=%v after=%v", before, s.LastActivity())
}
var out map[string]string
if err := json.Unmarshal(resp.Result, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out["ok"] != "true" {
t.Fatalf("got %v", out)
}
}
func TestService_HandlerError(t *testing.T) {
s := NewService(map[string]MethodHandler{
"fail": func(ctx context.Context, params json.RawMessage) (any, *rpc.Error) {
return nil, rpc.NewError(rpc.CodeNotLoggedIn, "no session")
},
})
resp := s.Handle(context.Background(), rpc.Request{Method: "fail"})
if resp.Error == nil || resp.Error.Code != rpc.CodeNotLoggedIn {
t.Fatalf("expected not_logged_in, got %+v", resp.Error)
}
}
-142
View File
@@ -1,142 +0,0 @@
//go:build unix
package broker
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/socket"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
)
// EnsureRunningOptions tunes EnsureRunning for tests. Production code
// passes a zero value.
type EnsureRunningOptions struct {
// BrokerBinary overrides the auto-detected sherlock-broker path.
// When empty, EnsureRunning resolves a sibling of os.Executable()
// then falls back to $PATH.
BrokerBinary string
// SpawnTimeout is how long to wait for the socket to appear after
// forking. Zero means 2 seconds.
SpawnTimeout time.Duration
// SocketPath overrides the canonical broker socket. Tests use
// this to drive a temp socket.
SocketPath string
// ExtraEnv is appended to the child env. Useful for tests pinning
// XDG_RUNTIME_DIR.
ExtraEnv []string
}
// EnsureRunning dials the broker socket, forking + setsid-detaching
// the broker binary if no one is listening. Returns a connected Client.
//
// Decision #10: zero host setup. The wrapper CLI is what starts the
// broker the first time it's needed.
func EnsureRunning(ctx context.Context, opts EnsureRunningOptions) (*socket.Client, error) {
sockPath := opts.SocketPath
if sockPath == "" {
p, err := xdg.SocketPath()
if err != nil {
return nil, err
}
sockPath = p
}
if c, err := socket.Dial(sockPath); err == nil {
return c, nil
}
// Not running. Fork a fresh broker.
bin := opts.BrokerBinary
if bin == "" {
resolved, err := resolveBrokerBinary()
if err != nil {
return nil, err
}
bin = resolved
}
logPath, err := xdg.LogPath()
if err != nil {
return nil, err
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("broker: open log %s: %w", logPath, err)
}
cmd := exec.Command(bin, "daemon")
cmd.Stdin = nil
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Env = append(os.Environ(), opts.ExtraEnv...)
// setsid so the child outlives the parent and isn't part of the
// CLI's process group.
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
_ = logFile.Close()
return nil, fmt.Errorf("broker: spawn %s: %w", bin, err)
}
// We don't Wait on the child — it's detached. Release the fd we
// kept open in the parent.
_ = logFile.Close()
// Release the child so it isn't reparented to us / left as a zombie
// if the CLI sticks around (it normally execs into the agent and
// disappears, but tests don't).
_ = cmd.Process.Release()
deadline := opts.SpawnTimeout
if deadline <= 0 {
deadline = 2 * time.Second
}
return pollDial(ctx, sockPath, deadline)
}
func pollDial(ctx context.Context, sockPath string, timeout time.Duration) (*socket.Client, error) {
end := time.Now().Add(timeout)
for {
if c, err := socket.Dial(sockPath); err == nil {
return c, nil
}
if time.Now().After(end) {
return nil, fmt.Errorf("broker: socket %s did not appear within %s", sockPath, timeout)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
}
// resolveBrokerBinary looks for a sherlock-broker sibling of the
// running executable (so a `go install`'d pair stays consistent) then
// falls back to $PATH.
func resolveBrokerBinary() (string, error) {
const name = "sherlock-broker"
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), name)
if isExecutable(candidate) {
return candidate, nil
}
}
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
return "", fmt.Errorf("broker: could not locate %s on PATH or beside sherlock binary", name)
}
func isExecutable(p string) bool {
fi, err := os.Stat(p)
if err != nil || fi.IsDir() {
return false
}
return fi.Mode()&0o111 != 0
}
+14 -10
View File
@@ -1,14 +1,13 @@
// Package keyring is sherlock's gateway to the OS credential store.
//
// Decision #8 (Phase 1): persist tokens in the OS keyring (Secret
// Service on Linux, Keychain on macOS, Credential Manager on Windows).
// We do not write plaintext or age-encrypted tokens to disk.
// Decision #8: persist tokens in the OS keyring (Secret Service on
// Linux, Keychain on macOS, Credential Manager on Windows). No
// plaintext or age-encrypted blobs on disk.
//
// Both `sherlock` and `sherlock-broker` MUST call Probe at startup
// and exit early (with platform-specific guidance) if the keyring
// is unavailable. The broker holds the long-lived refresh token, so
// running without a real keyring would silently lose tokens on every
// restart — better to fail loudly.
// The CLI MUST call Probe at startup and exit early (with
// platform-specific guidance) if the keyring is unavailable. Without
// the keyring, refresh tokens would be lost on every command — better
// to fail loudly.
package keyring
import (
@@ -87,7 +86,10 @@ func Probe() error {
return nil
}
// TokenSet is what the broker persists after a successful Authentik flow.
// TokenSet is what sherlock persists after a successful Authentik
// flow. ClientID + Scopes are recorded alongside the tokens so that
// background refresh works without depending on environment variables
// set in the original login shell.
type TokenSet struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token,omitempty"`
@@ -95,6 +97,8 @@ type TokenSet struct {
IDExpiresAt time.Time `json:"id_expires_at"`
RefreshExpAt time.Time `json:"refresh_expires_at,omitempty"`
Issuer string `json:"issuer"`
ClientID string `json:"client_id,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Subject string `json:"sub"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
@@ -103,7 +107,7 @@ type TokenSet struct {
// Empty reports whether the TokenSet carries no usable session.
func (t TokenSet) Empty() bool { return t.IDToken == "" && t.RefreshToken == "" }
// Store is the typed wrapper the broker uses. Implementations are
// Store is the typed wrapper sherlock uses. Implementations are
// expected to be safe for concurrent use.
type Store interface {
GetAuthentikTokens() (TokenSet, error)
-113
View File
@@ -1,113 +0,0 @@
// Package rpc defines the wire types and error codes for sherlock's
// JSON-over-newline RPC, spoken on the broker UDS at
// $XDG_RUNTIME_DIR/sherlock.sock.
//
// Wire format: each Request and each Response is a single JSON object
// terminated by '\n'. The framing is documented in docs/rpc.md.
package rpc
import (
"encoding/json"
"fmt"
)
// Method names. Constants live here so client and server agree on spelling.
const (
MethodStatus = "status"
MethodWhoami = "whoami"
MethodLoginStart = "login_start"
MethodLoginWait = "login_wait"
MethodLogout = "logout"
MethodGetIDToken = "get_id_token"
MethodShutdown = "shutdown"
)
// ErrorCode is the numeric machine-readable code on a failed Response.
type ErrorCode int
const (
CodeUnknownMethod ErrorCode = 1
CodeBadRequest ErrorCode = 2
CodeInternal ErrorCode = 3
CodeNotLoggedIn ErrorCode = 4
CodeKeyringUnavailable ErrorCode = 5
CodeFlowExpired ErrorCode = 6
CodeNotConfigured ErrorCode = 7
CodeAlreadyInFlight ErrorCode = 8
)
func (c ErrorCode) String() string {
switch c {
case CodeUnknownMethod:
return "unknown_method"
case CodeBadRequest:
return "bad_request"
case CodeInternal:
return "internal"
case CodeNotLoggedIn:
return "not_logged_in"
case CodeKeyringUnavailable:
return "keyring_unavailable"
case CodeFlowExpired:
return "flow_expired"
case CodeNotConfigured:
return "not_configured"
case CodeAlreadyInFlight:
return "already_in_flight"
default:
return fmt.Sprintf("code_%d", int(c))
}
}
// Request is one JSON object on one line.
type Request struct {
ID uint64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// Response is one JSON object on one line. Exactly one of Result/Error is set.
type Response struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
// Error is the structured failure payload on a Response.
type Error struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
}
func (e *Error) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// NewError is a convenience for handlers that want to fail a request.
func NewError(code ErrorCode, msg string) *Error {
return &Error{Code: code, Message: msg}
}
// Errorf is the printf-style sibling of NewError.
func Errorf(code ErrorCode, format string, a ...any) *Error {
return &Error{Code: code, Message: fmt.Sprintf(format, a...)}
}
// MarshalParams is a small helper so handlers don't repeat the
// json.Marshal + RawMessage dance.
func MarshalParams(v any) (json.RawMessage, error) {
if v == nil {
return nil, nil
}
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
return json.RawMessage(b), nil
}
// MarshalResult is the symmetric helper for Response.Result.
func MarshalResult(v any) (json.RawMessage, error) { return MarshalParams(v) }
-69
View File
@@ -1,69 +0,0 @@
package rpc
import (
"encoding/json"
"testing"
)
func TestErrorCodeString(t *testing.T) {
cases := map[ErrorCode]string{
CodeUnknownMethod: "unknown_method",
CodeBadRequest: "bad_request",
CodeInternal: "internal",
CodeNotLoggedIn: "not_logged_in",
CodeKeyringUnavailable: "keyring_unavailable",
CodeFlowExpired: "flow_expired",
CodeNotConfigured: "not_configured",
CodeAlreadyInFlight: "already_in_flight",
ErrorCode(99): "code_99",
}
for code, want := range cases {
if got := code.String(); got != want {
t.Errorf("%d.String() = %q, want %q", int(code), got, want)
}
}
}
func TestRequestRoundTrip(t *testing.T) {
params, err := MarshalParams(map[string]string{"foo": "bar"})
if err != nil {
t.Fatalf("MarshalParams: %v", err)
}
req := Request{ID: 42, Method: "status", Params: params}
b, err := json.Marshal(req)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var got Request
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got.ID != 42 || got.Method != "status" {
t.Fatalf("round-trip lost fields: %+v", got)
}
var p map[string]string
if err := json.Unmarshal(got.Params, &p); err != nil {
t.Fatalf("params: %v", err)
}
if p["foo"] != "bar" {
t.Fatalf("params: %v", p)
}
}
func TestResponseErrorRoundTrip(t *testing.T) {
resp := Response{ID: 7, Error: NewError(CodeNotLoggedIn, "no session")}
b, err := json.Marshal(resp)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var got Response
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got.Error == nil || got.Error.Code != CodeNotLoggedIn {
t.Fatalf("error not preserved: %+v", got.Error)
}
if got.Error.Error() != "not_logged_in: no session" {
t.Fatalf("Error() = %q", got.Error.Error())
}
}
-10
View File
@@ -1,10 +0,0 @@
package socket_test
import "net"
// dialRaw is a tiny helper used by tests that need to send raw bytes
// without going through the RPC client (to exercise framing/decode
// error paths).
func dialRaw(path string) (net.Conn, error) {
return net.Dial("unix", path)
}
-251
View File
@@ -1,251 +0,0 @@
// Package socket implements a tiny JSON-over-newline RPC transport on
// top of a Unix domain socket. The broker is the server; the sherlock
// CLI and (later) per-service MCPs are clients.
//
// Framing: each rpc.Request and rpc.Response is one JSON object
// terminated by a single '\n'. No length prefixes, no chunked frames.
// This keeps the protocol trivially debuggable with `socat - UNIX-CONNECT:...`.
package socket
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/rpc"
)
// Max bytes for a single framed JSON object. Plenty of headroom for
// status / token payloads; guards against runaway parser allocations.
const maxFrameBytes = 1 << 20 // 1 MiB
// Handler turns a request into a response. Implementations should not
// write directly to the connection — the server takes care of framing.
type Handler interface {
Handle(ctx context.Context, req rpc.Request) rpc.Response
}
// HandlerFunc adapts a function to the Handler interface.
type HandlerFunc func(ctx context.Context, req rpc.Request) rpc.Response
func (f HandlerFunc) Handle(ctx context.Context, req rpc.Request) rpc.Response {
return f(ctx, req)
}
// Server listens on a Unix socket and dispatches each framed request
// to a Handler on its own goroutine per connection.
type Server struct {
ln net.Listener
handler Handler
wg sync.WaitGroup
closing atomic.Bool
onAccept func() // test hook: called once per accepted conn before dispatch.
}
// Listen creates the socket at path, removing any stale leftover file.
// Mode 0600 ensures only the owner can dial.
func Listen(path string, handler Handler) (*Server, error) {
if handler == nil {
return nil, errors.New("socket: nil handler")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("socket: mkdir parent: %w", err)
}
// Best-effort cleanup of a stale socket file. We deliberately don't
// touch it if another broker is actively listening — Listen below
// will fail with EADDRINUSE in that case.
if fi, err := os.Stat(path); err == nil && fi.Mode()&os.ModeSocket != 0 {
_ = os.Remove(path)
}
ln, err := net.Listen("unix", path)
if err != nil {
return nil, fmt.Errorf("socket: listen %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
_ = ln.Close()
return nil, fmt.Errorf("socket: chmod %s: %w", path, err)
}
return &Server{ln: ln, handler: handler}, nil
}
// Addr returns the socket path.
func (s *Server) Addr() string { return s.ln.Addr().String() }
// Serve accepts connections until Close is called or ctx is cancelled.
// Returns nil on a clean shutdown.
func (s *Server) Serve(ctx context.Context) error {
go func() {
<-ctx.Done()
_ = s.Close()
}()
for {
conn, err := s.ln.Accept()
if err != nil {
if s.closing.Load() {
s.wg.Wait()
return nil
}
return fmt.Errorf("socket: accept: %w", err)
}
if s.onAccept != nil {
s.onAccept()
}
s.wg.Add(1)
go s.serveConn(ctx, conn)
}
}
// Close stops accepting new connections and removes the socket file.
// In-flight handlers continue until they return; Serve waits for them.
func (s *Server) Close() error {
if !s.closing.CompareAndSwap(false, true) {
return nil
}
err := s.ln.Close()
_ = os.Remove(s.Addr())
return err
}
func (s *Server) serveConn(ctx context.Context, conn net.Conn) {
defer s.wg.Done()
defer func() { _ = conn.Close() }()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), maxFrameBytes)
w := bufio.NewWriter(conn)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req rpc.Request
var resp rpc.Response
if err := json.Unmarshal(line, &req); err != nil {
resp = rpc.Response{Error: rpc.Errorf(rpc.CodeBadRequest, "decode request: %v", err)}
} else {
resp = s.handler.Handle(ctx, req)
resp.ID = req.ID
}
if err := writeFrame(w, resp); err != nil {
return
}
}
// Drain a non-EOF scanner error silently — the client likely hung up.
}
func writeFrame(w *bufio.Writer, resp rpc.Response) error {
b, err := json.Marshal(resp)
if err != nil {
// Last-ditch: send a generic internal error. If even that fails
// we have nothing useful to do but close the connection.
fallback, _ := json.Marshal(rpc.Response{
ID: resp.ID,
Error: rpc.NewError(rpc.CodeInternal, "marshal response failed"),
})
if _, err := w.Write(fallback); err != nil {
return err
}
if err := w.WriteByte('\n'); err != nil {
return err
}
return w.Flush()
}
if _, err := w.Write(b); err != nil {
return err
}
if err := w.WriteByte('\n'); err != nil {
return err
}
return w.Flush()
}
// Client is a thin RPC client over the UDS. It is safe to use a single
// Client from multiple goroutines: Call serialises writes and matches
// responses by request ID.
type Client struct {
conn net.Conn
mu sync.Mutex
rd *bufio.Reader
nextID atomic.Uint64
}
// Dial connects to the broker UDS at path. The caller owns the returned
// Client and must Close it when done.
func Dial(path string) (*Client, error) {
conn, err := net.Dial("unix", path)
if err != nil {
return nil, err
}
return &Client{conn: conn, rd: bufio.NewReader(conn)}, nil
}
// Close closes the underlying socket.
func (c *Client) Close() error { return c.conn.Close() }
// Call sends a request and unmarshals the response.
//
// We hold the per-client mutex for the whole round-trip. Sherlock RPC
// calls are short (sub-second) and never streamed, so request pipelining
// would be pointless — keeping the protocol synchronous keeps the client
// trivially correct and lets us reuse a single connection across calls.
func (c *Client) Call(ctx context.Context, method string, params, result any) error {
c.mu.Lock()
defer c.mu.Unlock()
id := c.nextID.Add(1)
pb, err := rpc.MarshalParams(params)
if err != nil {
return fmt.Errorf("socket: marshal params: %w", err)
}
req := rpc.Request{ID: id, Method: method, Params: pb}
reqBytes, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("socket: marshal request: %w", err)
}
// Honour ctx with a connection-level deadline. Cheap and avoids a
// reader goroutine just for cancellation.
if dl, ok := ctx.Deadline(); ok {
_ = c.conn.SetDeadline(dl)
defer func() { _ = c.conn.SetDeadline(time.Time{}) }()
}
if _, err := c.conn.Write(append(reqBytes, '\n')); err != nil {
return fmt.Errorf("socket: write: %w", err)
}
line, err := c.rd.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return fmt.Errorf("socket: broker closed connection")
}
return fmt.Errorf("socket: read: %w", err)
}
var resp rpc.Response
if err := json.Unmarshal(line, &resp); err != nil {
return fmt.Errorf("socket: decode response: %w", err)
}
if resp.ID != id {
return fmt.Errorf("socket: response id mismatch: got %d want %d", resp.ID, id)
}
if resp.Error != nil {
return resp.Error
}
if result == nil || len(resp.Result) == 0 {
return nil
}
if err := json.Unmarshal(resp.Result, result); err != nil {
return fmt.Errorf("socket: decode result: %w", err)
}
return nil
}
-166
View File
@@ -1,166 +0,0 @@
package socket_test
import (
"context"
"encoding/json"
"path/filepath"
"sync"
"testing"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/rpc"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/socket"
)
func newServer(t *testing.T, handler socket.Handler) (string, func()) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.sock")
srv, err := socket.Listen(path, handler)
if err != nil {
t.Fatalf("Listen: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
if err := srv.Serve(ctx); err != nil {
t.Errorf("Serve: %v", err)
}
}()
return path, func() {
cancel()
_ = srv.Close()
<-done
}
}
func TestRoundTrip_Success(t *testing.T) {
handler := socket.HandlerFunc(func(_ context.Context, req rpc.Request) rpc.Response {
if req.Method != "echo" {
return rpc.Response{Error: rpc.NewError(rpc.CodeUnknownMethod, "nope")}
}
out, _ := rpc.MarshalResult(map[string]string{"got": "ok"})
return rpc.Response{Result: out}
})
path, stop := newServer(t, handler)
defer stop()
c, err := socket.Dial(path)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer func() { _ = c.Close() }()
var resp map[string]string
if err := c.Call(context.Background(), "echo", nil, &resp); err != nil {
t.Fatalf("Call: %v", err)
}
if resp["got"] != "ok" {
t.Fatalf("got %v", resp)
}
}
func TestRoundTrip_Error(t *testing.T) {
handler := socket.HandlerFunc(func(_ context.Context, req rpc.Request) rpc.Response {
return rpc.Response{Error: rpc.NewError(rpc.CodeNotLoggedIn, "session missing")}
})
path, stop := newServer(t, handler)
defer stop()
c, err := socket.Dial(path)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "status", nil, nil)
if err == nil {
t.Fatal("expected error")
}
rpcErr, ok := err.(*rpc.Error)
if !ok {
t.Fatalf("expected *rpc.Error, got %T", err)
}
if rpcErr.Code != rpc.CodeNotLoggedIn {
t.Fatalf("code = %v", rpcErr.Code)
}
}
func TestRoundTrip_BadJSON(t *testing.T) {
handler := socket.HandlerFunc(func(_ context.Context, req rpc.Request) rpc.Response {
return rpc.Response{}
})
path, stop := newServer(t, handler)
defer stop()
// Send garbage directly so we exercise the server-side decode path.
conn, err := dialRaw(path)
if err != nil {
t.Fatalf("dialRaw: %v", err)
}
defer func() { _ = conn.Close() }()
if _, err := conn.Write([]byte("not json\n")); err != nil {
t.Fatalf("Write: %v", err)
}
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read: %v", err)
}
var resp rpc.Response
if err := json.Unmarshal(buf[:n], &resp); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if resp.Error == nil || resp.Error.Code != rpc.CodeBadRequest {
t.Fatalf("expected bad_request, got %+v", resp.Error)
}
}
func TestConcurrentCalls(t *testing.T) {
handler := socket.HandlerFunc(func(_ context.Context, req rpc.Request) rpc.Response {
var in struct{ N int }
_ = json.Unmarshal(req.Params, &in)
out, _ := rpc.MarshalResult(map[string]int{"n": in.N})
return rpc.Response{Result: out}
})
path, stop := newServer(t, handler)
defer stop()
const goroutines = 20
const callsEach = 50
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, err := socket.Dial(path)
if err != nil {
t.Errorf("Dial: %v", err)
return
}
defer func() { _ = c.Close() }()
for j := 0; j < callsEach; j++ {
var out map[string]int
err := c.Call(context.Background(), "echo",
map[string]int{"N": j}, &out)
if err != nil {
t.Errorf("Call: %v", err)
return
}
if out["n"] != j {
t.Errorf("got %d want %d", out["n"], j)
return
}
}
}()
}
doneCh := make(chan struct{})
go func() { wg.Wait(); close(doneCh) }()
select {
case <-doneCh:
case <-time.After(10 * time.Second):
t.Fatal("concurrent calls timed out")
}
}
+5 -21
View File
@@ -45,31 +45,15 @@ func ConfigHome() (string, error) {
return ensureDir(filepath.Join(home, ".config"), 0o700)
}
// SocketPath returns the canonical UDS path the broker listens on.
func SocketPath() (string, error) {
// RefreshLockPath returns the canonical flock path used by EnsureFresh
// to serialise concurrent Authentik refresh attempts across sherlock
// invocations.
func RefreshLockPath() (string, error) {
d, err := RuntimeDir()
if err != nil {
return "", err
}
return filepath.Join(d, "sherlock.sock"), nil
}
// PidPath returns the canonical PID/lock file path for the broker.
func PidPath() (string, error) {
d, err := RuntimeDir()
if err != nil {
return "", err
}
return filepath.Join(d, "sherlock.pid"), nil
}
// LogPath returns the canonical log file path for the forked broker.
func LogPath() (string, error) {
d, err := RuntimeDir()
if err != nil {
return "", err
}
return filepath.Join(d, "sherlock.log"), nil
return filepath.Join(d, "sherlock.refresh.lock"), nil
}
// MCPConfigDir returns $XDG_RUNTIME_DIR/sherlock, where per-agent MCP
+1 -3
View File
@@ -55,9 +55,7 @@ func TestDerivedPaths(t *testing.T) {
fn func() (string, error)
want string
}{
{"SocketPath", SocketPath, filepath.Join(dir, "sherlock.sock")},
{"PidPath", PidPath, filepath.Join(dir, "sherlock.pid")},
{"LogPath", LogPath, filepath.Join(dir, "sherlock.log")},
{"RefreshLockPath", RefreshLockPath, filepath.Join(dir, "sherlock.refresh.lock")},
{"MCPConfigDir", MCPConfigDir, filepath.Join(dir, "sherlock")},
}
for _, c := range checks {
+8 -8
View File
@@ -228,20 +228,20 @@ Reuses the existing gssh server (`gssh.alexandru.macocian.me`) — sherlock does
- **Name:** `sherlock`. (Replaces "sherlock" everywhere — `sherlock-broker`, `sherlock-mcp`, `sherlock copilot …`, etc.)
- **Repo location:** new repo `Charlie/sherlock`, cloned to `~/Dev/charlie/sherlock/` (sibling of the existing per-app stack repos like `gitea/`, `runners/`, etc.). Added to `docs/inventory.md` under a new "Operator tooling" section and linked from `project-charlie/README.md`.
- **Language:** Go. Single static binaries per `cmd/<binary>/`; best-in-class OAuth/OIDC libs (`golang.org/x/oauth2`, `coreos/go-oidc`); official MCP Go SDK; native UDS + systemd integration.
- **Phase 2 Gitea auth model:** broker mints an OIDC token *as the operator* via Authentik. Every MCP call shows up under the operator in Gitea's audit log. No dedicated `sherlock` service account.
- **Service-registry format:** TOML, under `~/.config/sherlock/services.d/*.toml`. Hot-reloaded by the broker.
- **Language:** Go. Single static binary `cmd/sherlock/`; best-in-class OAuth/OIDC libs (`golang.org/x/oauth2`, `coreos/go-oidc`); official MCP Go SDK; native Unix primitives (flock, syscall.Exec).
- **Phase 2 Gitea auth model:** the gitea-mcp uses an OIDC token *as the operator* via Authentik. Every MCP call shows up under the operator in Gitea's audit log. No dedicated `sherlock` service account.
- **Service-registry format:** TOML, under `~/.config/sherlock/services.d/*.toml`. Loaded at the start of each CLI invocation (no daemon to hot-reload).
- **SSH backend:** reuse the existing gssh server. `gssh-mcp` is a thin JWT-authenticated HTTP+WS client; no local SSH-cert work in sherlock. See [docs/gssh-integration.md](docs/gssh-integration.md).
- **Wrapper extensibility:** agent-agnostic. Each supported agent CLI (Copilot, Claude Code, Aider, …) is a single Go file under `internal/agent/` that registers itself with the package via `init()`. `sherlock copilot` and `sherlock claude` are sugar for `sherlock run copilot` / `sherlock run claude`. Adding a new agent is a small code change, by design — per-CLI quirks (auth subcommands, MCP config schema, env-var quirks) need a real code home. The TOML-overlay design from the Phase 0 plan was tried in Phase 1 and dropped. See [docs/agents.md](docs/agents.md).
- **Docs convention:** `README.md` is TOC only; every topic lives as its own file under `docs/`. New concerns get new files, never appended sections.
- **Token persistence (Phase 1):** OS keyring via `zalando/go-keyring`. Strict pre-flight (`keyring.Probe()`) at startup of both `sherlock` and `sherlock-broker`; fail fast with exit code 3 and a per-OS hint if Secret Service / Keychain / Credential Manager is unavailable. See [docs/storage.md](docs/storage.md).
- **RPC framing (Phase 1):** JSON-over-newline on the UDS at `$XDG_RUNTIME_DIR/sherlock.sock`. One JSON object per line, both directions. Debuggable with `socat`/`nc`. See [docs/rpc.md](docs/rpc.md).
- **Broker lifecycle (Phase 1):** forked child process. The wrapper CLI dials the socket; if no one is listening it `fork+exec`s `sherlock-broker daemon` with `setsid`, polls the socket, then proceeds. Per-user PID-file flock prevents double-start; broker auto-exits after `SHERLOCK_BROKER_IDLE` (default 1h). No systemd in Phase 1.
- **Loopback redirect port:** `127.0.0.1:6990` (unassigned per IANA). The actual Authentik OAuth2 provider config is deferred — the broker ships with empty defaults and `login_start` returns `not_configured` until `SHERLOCK_AUTHENTIK_ISSUER` / `SHERLOCK_AUTHENTIK_CLIENT_ID` are set. End-to-end flow is exercised by an integration test against a stub Authentik (`internal/authn/flow_test.go`).
- **Token persistence:** OS keyring via `zalando/go-keyring`. Strict pre-flight (`keyring.Probe()`) at startup of every CLI invocation; fail fast with exit code 3 and a per-OS hint if Secret Service / Keychain / Credential Manager is unavailable. See [docs/storage.md](docs/storage.md).
- **Loopback redirect port:** `127.0.0.1:6990` (unassigned per IANA). Bound only for the duration of `sherlock login`. The actual Authentik OAuth2 provider config is deferred — sherlock ships with empty defaults and `sherlock login` returns `not_configured` until `SHERLOCK_AUTHENTIK_ISSUER` / `SHERLOCK_AUTHENTIK_CLIENT_ID` are set. End-to-end flow is exercised by an integration test against a stub Authentik (`internal/authn/login_test.go`).
- **No daemon (reverses Phase 1 #9 + #10):** the Phase 1 design had a `sherlock-broker` daemon with UDS RPC (`docs/rpc.md`), JSON-over-newline framing, fork+setsid lifecycle, PID-file flock, and idle-timeout watcher. It was scrapped in the post-Phase-1 refactor — see [docs/architecture.md#why-there-is-no-daemon](docs/architecture.md#why-there-is-no-daemon). Multi-process refresh races are serialised via an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock` in `internal/authn/refresh.go`. Net: -800 LoC, no `internal/{broker,socket,rpc}/` packages, no `cmd/sherlock-broker/` binary.
## Still open
Tracked here and revisited at the top of the relevant phase.
1. **Phase 2 — Authentik provider creation:** stand up the actual `sherlock` OAuth2 provider in Authentik and document the env vars in `docs/storage.md` (carry-over from the Phase 1 deferral above).
2. **Phase 3gssh completion semantics:** keep the client-side `__SHERLOCK_DONE__` sentinel, or land a server-side one-shot run-and-close mode in gssh.
2. **Phase 2own-oauth UX:** confirm `sherlock auth <service>` is the right surface for non-Authentik services like GitHub.com (vs. an interactive `sherlock-mcp.oauth.consent` flow). The no-daemon refactor pushed this off — without a long-lived listener, an explicit command is the natural way to bind the loopback port.
3. **Phase 3 — gssh completion semantics:** keep the client-side `__SHERLOCK_DONE__` sentinel, or land a server-side one-shot run-and-close mode in gssh.