From bb1aa3acbdf42c494ab5dcc3fd7bc4157db18d0d Mon Sep 17 00:00:00 2001 From: Ns2Kracy <89824014+Ns2Kracy@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:28:54 +0800 Subject: [PATCH] feat(gateway): named gateway keys with per-client usage (#344) * feat(gui): show and filter usage for each API key * feat(gateway): issue per-user caller keys and attribute usage * feat(gui): manage gateway users and filter caller-key usage * test: verify caller-key failover and document user management * refactor(gateway): use named caller keys without user accounts * feat(gui): manage named caller keys with existing account styles * fix: remove user breakdown from CLI and leftover gateway styling * fix(usage): drop stale user field from request response * feat(gateway): authenticate LAN calls with named caller keys * fix(gateway): preserve LAN key rotation in the named caller store * fix(gui): retain native LAN key copy and rotation controls * fix(gui): inherit the global theme for API key inputs * fix(usage): show client API keys using the existing terminology * fix(gateway): rotate named keys without changing usage identity * fix(gui): unify gateway keys and selectable connection examples Keep sharing controls in Settings and manage credentials only in Gateway. Refresh the named-key list on navigation, ignore stale reads after key actions, and generate all API examples from the selected address and enabled key. * fix(gui): keep caller key names literal in connection menus * fix(access): use Magpie as default key and drop user migration * refactor(gui): name caller key source and tests consistently * refactor(usage): split provider-key accounting out of caller-key PR * fix(gateway): preserve LAN compatibility and add headless key management * fix(gui): clarify gateway keys and confirm destructive actions * test(gateway): guard caller attribution under 120 concurrent requests * fix(gateway): keep open access, tolerate migration errors and revoke legacy keys * fix(backup): preserve named gateway keys and honor no-keys restores * fix(gui): keep local API-key semantics and disambiguate shared credentials * docs(gateway): keep review scope focused and append browser test documentation * fix(backup): retain existing default keys when old backups lack their store * fix(gateway): attribute video creation usage to the caller key * fix(access): explain default key revocation blocked by read-only settings * test(usage): preserve provider and caller identities after rebase * test(gui): reconcile caller key fixtures with merged main * docs(usage): clarify caller coverage and independent provider accounting * fix(gui): initialize fast members for model-shortcut groups Latest main introduced fast-member arrays but omitted the model shortcut's draft. The existing unlisted-models browser regression fails unchanged on main. Initialize fast to an empty list and assert a standard-speed group is saved, without relaxing existing assertions. * fix(access): retain legacy LAN access when the key store cannot be created Allow only the configured legacy credential before either migration marker exists. Refuse revoked mirrors, disabled matches and unreadable stores; keep the fallback's caller identity stable when migration later succeeds. Cover read-only configuration directories, remote credential transports and revocation. * fix(backup): save settings before replacing gateway credentials Leave the key store untouched when settings validation or writes fail. Restore the previous settings association when the key store refuses its write, and report a rollback failure separately. Cover invalid settings and read-only settings/store failures. * fix(gui): reset missing caller filters and focus key controls without scrolling * test(provider): synchronize Zhipu quota fixture observations --- README.md | 61 +++- gateway_keys_cli.go | 65 ++++ gateway_keys_cli_test.go | 80 ++++ internal/access/access.go | 345 ++++++++++++++++++ internal/access/access_test.go | 302 +++++++++++++++ internal/access/legacy_fallback_test.go | 101 +++++ internal/access/legacy_revocation_test.go | 133 +++++++ internal/backup/backup.go | 74 +++- internal/backup/caller_keys_test.go | 212 +++++++++++ internal/gateway/caller_regression_test.go | 306 ++++++++++++++++ internal/gateway/caller_usage_test.go | 323 ++++++++++++++++ internal/gateway/caller_video_usage_test.go | 57 +++ internal/gateway/codex_backend.go | 2 +- internal/gateway/decide.go | 2 +- internal/gateway/draw.go | 2 +- internal/gateway/gateway.go | 10 +- internal/gateway/lan.go | 133 +++---- internal/gateway/lan_test.go | 84 +++-- internal/gateway/quotas_lan_test.go | 24 +- internal/gateway/session_route_test.go | 28 +- internal/gateway/video.go | 2 +- internal/gui/api.go | 16 +- internal/gui/assets/app.css | 12 +- internal/gui/assets/app.js | 157 +++++--- internal/gui/assets/caller-keys.js | 187 ++++++++++ internal/gui/assets/i18n.js | 32 +- internal/gui/assets/index.html | 9 + internal/gui/caller_keys.go | 41 +++ internal/gui/caller_keys_test.go | 111 ++++++ .../gui/caller_migration_readonly_test.go | 32 ++ internal/gui/lan_keys_test.go | 161 ++++++++ internal/gui/providers.go | 7 + internal/gui/settings_keep_test.go | 1 + internal/gui/tests/README.md | 47 +++ internal/gui/tests/api-key-theme.test.cjs | 132 +++++++ internal/gui/tests/api-key-usage.test.cjs | 167 +++++++++ internal/gui/tests/fixtures/caller-keys.cjs | 123 +++++++ .../gui/tests/gateway-caller-keys.test.cjs | 135 +++++++ .../gui/tests/gateway-connect-keys.test.cjs | 182 +++++++++ internal/gui/tests/gateway-fold.test.cjs | 5 + internal/gui/tests/unlisted-models.test.cjs | 1 + internal/gui/tests/usage-ledger.test.cjs | 37 +- internal/gui/usage.go | 57 ++- internal/provider/zhipu_team_key_test.go | 6 + internal/settings/settings.go | 7 +- internal/usage/caller_provider_test.go | 180 +++++++++ internal/usage/ledger.go | 22 +- internal/usage/ledger_test.go | 6 +- internal/usage/request_page.go | 50 ++- internal/usage/request_snapshot.go | 3 +- internal/usage/session_provider_test.go | 4 +- internal/usage/usage.go | 21 +- main.go | 3 + providers_cli.go | 4 +- serve_cli_test.go | 16 +- usage_cli.go | 8 + 56 files changed, 4080 insertions(+), 248 deletions(-) create mode 100644 gateway_keys_cli.go create mode 100644 gateway_keys_cli_test.go create mode 100644 internal/access/access.go create mode 100644 internal/access/access_test.go create mode 100644 internal/access/legacy_fallback_test.go create mode 100644 internal/access/legacy_revocation_test.go create mode 100644 internal/backup/caller_keys_test.go create mode 100644 internal/gateway/caller_regression_test.go create mode 100644 internal/gateway/caller_usage_test.go create mode 100644 internal/gateway/caller_video_usage_test.go create mode 100644 internal/gui/assets/caller-keys.js create mode 100644 internal/gui/caller_keys.go create mode 100644 internal/gui/caller_keys_test.go create mode 100644 internal/gui/caller_migration_readonly_test.go create mode 100644 internal/gui/lan_keys_test.go create mode 100644 internal/gui/tests/api-key-theme.test.cjs create mode 100644 internal/gui/tests/api-key-usage.test.cjs create mode 100644 internal/gui/tests/fixtures/caller-keys.cjs create mode 100644 internal/gui/tests/gateway-caller-keys.test.cjs create mode 100644 internal/gui/tests/gateway-connect-keys.test.cjs create mode 100644 internal/usage/caller_provider_test.go diff --git a/README.md b/README.md index 063c8370..44c46612 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,54 @@ Its list is the models the shared magpie's agents are shown, each named with its provider there (`Claude Sonnet 5 · Relay A · office`), and its image models are listed under Settings → Images and draw through it. +The gateway issues **gateway keys** for clients, separate from a provider's +upstream API keys. Turn on **Settings → Share on local network**, then open +**Gateway → Gateway keys → Add gateway key**. This block appears only while +sharing is on. Create a named key for each client and copy it from its row. +Rename, disable, rotate or remove keys independently; rotation and removal +ask for confirmation. Rotation keeps the name, enabled state and usage +history; other keys are unchanged. While sharing is on, **Gateway → Connect** +offers loopback and shared addresses, plus enabled gateway keys, for all +connection examples. With sharing off, Connect keeps the original **API key** +field and local `magpie` token, without a gateway-key picker. Its arbitrary +local option is **This computer**, distinct from the named **Magpie** key. + +For a headless gateway, use the CLI before exposing the port: + +```sh +magpie gateway-key add "Remote laptop" # prints the new credential once +magpie gateway-key list # ids, names, enabled state and masked keys +magpie gateway-key rotate # prints the replacement; identity stays the same +magpie gateway-key remove # revokes remote access +``` + +While LAN sharing is enabled, remote requests require an enabled gateway key +sent as Bearer, `x-api-key`, `x-goog-api-key` or `?key=`. Loopback remains +permissive: any token works, including a stale or disabled gateway key. +Only a valid, enabled key is attributed to its named identity. +Without sharing, an explicitly exposed `MAGPIE_ADDR` keeps its original open +access, including old `sk-magpie-…` tokens, without key authentication. + +**Usage → Overview → Gateway keys** groups calls by the client's key, never +the provider's credential. **Usage → Requests** offers the same filter; +CSV includes `caller_key_id` and `caller_key_name`. Deleted keys keep their +history. The usual local `magpie` token and older records stay unattributed. +Caller attribution includes chat, images, video creation and System One. +Provider attribution stays independent; CSV puts `provider_key_*` before +`caller_key_*`. +An existing LAN key becomes **Magpie** without changing the credential. +`lanKey` remains in settings for older Magpie versions. Disabling or removing +the default key replaces that mirror with a random non-empty revoked value; +rotation does not re-enable it. The key store records migration completion, +even if `lanKeyId` cannot be saved, so reads do not keep retrying that write. +A migration write failure is logged without preventing gateway startup, +CLI key management, or the Settings and key-list pages from opening. +If settings are read-only, changing the default key fails without changing +it: make `settings.json` writable and retry so older versions cannot keep +accepting its old credential. Independent named keys remain manageable. +Gateway credentials stay in `~/.config/magpie/caller-keys.json` (XDG-aware, +mode `0600`), never in usage records or list responses. + Baidu Qianfan's [Token Plans](https://cloud.baidu.com/doc/qianfan/s/Dmrabu8b6) are available as `baidu-qianfan`: a personal (个人版) and an enterprise (企业版) plan and pay as you go, each with its own Chat Completions, Responses and @@ -631,9 +679,12 @@ docker run -d --name magpie -p 127.0.0.1:3425:3425 -p 127.0.0.1:3430:3430 -v mag published on the host's loopback only; Docker's `-p 3425:3425` would put it on every interface of the host, past its firewall. To reach it from other machines, turn on Settings → Share on local network in the browser UI (or -put `"lan": true, "lanKey": "sk-magpie-…"` in `/config/magpie/settings.json`): -from then on a request from outside the container must carry that key as its -API key, and only then publish the port beyond 127.0.0.1. Inside the container +put `"lan": true` in `/config/magpie/settings.json`). Turning it on in Settings +creates a named **Magpie** key. With settings edited by hand, run +`magpie gateway-key add "Docker client"` in the container to create a key +without the browser UI. A request from outside the container must carry one of +those keys as its API key. Only then publish the port beyond 127.0.0.1. +Inside the container magpie only sees the container's own address (Docker's 172.17.x), so set `-e MAGPIE_PUBLIC_URL=http://:3425` (the port published on the host) for the address it shows and prints to be the one @@ -755,6 +806,10 @@ pictures picked for them, the settings, the profiles, every agent's model and the library (unless `--no-library`): the instruction sets, the MCP servers and the skills with their files (a file over 2 MB is left out). Without keys, a server's environment variables and headers that look like a key go empty. +Gateway credentials, their names, ids and disabled state travel encrypted +with Settings too. Restoring Settings replaces the gateway-key store with +the backed-up one. `--no-keys` leaves gateway credentials and their legacy +mirror out; restoring it preserves the destination's existing keys instead. Restoring the library replaces the one there — what it replaces is kept with the library's backups — and writes it into the agents on that machine. It is encrypted on your machine (AES-256-GCM, the key derived from the diff --git a/gateway_keys_cli.go b/gateway_keys_cli.go new file mode 100644 index 00000000..54bc2df2 --- /dev/null +++ b/gateway_keys_cli.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/yetone/magpie/internal/access" +) + +func gatewayKeys(args []string) error { return gatewayKeysTo(os.Stdout, args) } + +func gatewayKeysTo(out io.Writer, args []string) error { + args = args[1:] + if len(args) == 0 { + args = []string{"list"} + } + action := args[0] + if (action == "list" && len(args) != 1) || (action != "list" && len(args) != 2) { + return fmt.Errorf("usage: magpie gateway-key list | add | rotate | remove ") + } + switch action { + case "list", "add", "rotate", "remove": + default: + return fmt.Errorf("unknown gateway-key command %q", action) + } + access.MigrateLegacyLANKeyBestEffort() + if action == "list" { + keys, err := access.List() + if err != nil { + return err + } + w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tSTATE\tGATEWAY KEY") + for _, k := range keys { + state := "enabled" + if k.Off { + state = "disabled" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", k.ID, strings.Map(func(r rune) rune { + if r < 32 || r == 127 { + return ' ' + } + return r + }, k.Name), state, k.Masked) + } + return w.Flush() + } + in := access.Change{Key: args[1]} + if action == "add" { + in = access.Change{Name: args[1]} + } + secret, err := access.Update(action+"-key", in) + if err != nil { + return err + } + if secret != "" { + _, err = fmt.Fprintln(out, secret) + } else { + _, err = fmt.Fprintln(out, "Gateway key removed") + } + return err +} diff --git a/gateway_keys_cli_test.go b/gateway_keys_cli_test.go new file mode 100644 index 00000000..4e8e8b2b --- /dev/null +++ b/gateway_keys_cli_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/settings" +) + +func TestGatewayKeyCLI(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := settings.Save(settings.Settings{LAN: true}); err != nil { + t.Fatal(err) + } + call := func(args ...string) string { + t.Helper() + var out bytes.Buffer + if err := gatewayKeysTo(&out, append([]string{"gateway-key"}, args...)); err != nil { + t.Fatal(err) + } + return strings.TrimSpace(out.String()) + } + if got := call("list"); !strings.Contains(got, "GATEWAY KEY") { + t.Fatal(got) + } + secret := call("add", "Remote laptop") + keys, _ := access.List() + if len(keys) != 1 { + t.Fatal(keys) + } + id := keys[0].ID + if who, ok := access.Authenticate(secret); !ok || who.KeyID != id || who.KeyName != "Remote laptop" { + t.Fatal(who, ok) + } + if got := call(); strings.Contains(got, secret) || !strings.Contains(got, id) || !strings.Contains(got, "Remote laptop") { + t.Fatal("list must mask credentials", got) + } + next := call("rotate", id) + if next == secret { + t.Fatal("rotation reused the secret") + } + if _, ok := access.Authenticate(secret); ok { + t.Fatal("old credential works after rotation") + } + if who, ok := access.Authenticate(next); !ok || who.KeyID != id { + t.Fatal("rotation changed identity", who, ok) + } + call("remove", id) + if _, ok := access.Authenticate(next); ok { + t.Fatal("removed credential still works") + } + for _, args := range [][]string{{"add"}, {"add", ""}, {"list", "extra"}, {"rotate", "missing"}, {"remove", "missing"}, {"unknown", "x"}} { + if err := gatewayKeysTo(&bytes.Buffer{}, append([]string{"gateway-key"}, args...)); err == nil { + t.Error("accepted", args) + } + } +} + +func TestGatewayKeyListWithReadOnlyMigration(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := settings.Settings{LAN: true, LANKey: "sk-magpie-fixture-cli"} + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := os.Chmod(settings.Path(), 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(settings.Path(), 0o600) }) + if err := settings.Save(s); err == nil { + t.Skip("settings.json remains writable") + } + var out bytes.Buffer + if err := gatewayKeysTo(&out, []string{"gateway-key", "list"}); err != nil || !strings.Contains(out.String(), "Magpie") { + t.Fatal("read-only migration blocked CLI list", out.String(), err) + } +} diff --git a/internal/access/access.go b/internal/access/access.go new file mode 100644 index 00000000..4233d13b --- /dev/null +++ b/internal/access/access.go @@ -0,0 +1,345 @@ +// Package access keeps the named API keys issued by the gateway. +package access + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "unicode/utf8" + + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/settings" +) + +const Prefix = "sk-magpie-key-" +const legacyLANPrefix = "sk-magpie-" +const revokedLANPrefix = "sk-magpie-revoked-" + +func Managed(secret string) bool { + return strings.HasPrefix(secret, legacyLANPrefix) +} + +type Key struct { + ID string `json:"id"` + Name string `json:"name"` + Off bool `json:"off,omitempty"` + LAN bool `json:"lan,omitempty"` // default key and durable migration marker + Secret string `json:"secret,omitempty"` + Masked string `json:"masked,omitempty"` +} + +type Identity struct{ KeyID, KeyName string } +type contextKey struct{} + +func WithIdentity(ctx context.Context, who Identity) context.Context { + return context.WithValue(ctx, contextKey{}, who) +} +func Caller(ctx context.Context) Identity { who, _ := ctx.Value(contextKey{}).(Identity); return who } + +var mu sync.Mutex + +func Path() string { return filepath.Join(settings.Dir(), "caller-keys.json") } + +func load() ([]Key, error) { + b, err := os.ReadFile(Path()) + if errors.Is(err, os.ErrNotExist) { + return []Key{}, nil + } + if err != nil { + return nil, err + } + var keys []Key + err = json.Unmarshal(b, &keys) + return keys, err +} + +// List never returns credentials; only the administrator's copy action does. +func List() ([]Key, error) { + mu.Lock() + defer mu.Unlock() + keys, err := load() + if err != nil { + return nil, err + } + for i := range keys { + k := &keys[i] + if len(k.Secret) > 8 { + prefix := legacyLANPrefix + if strings.HasPrefix(k.Secret, Prefix) { + prefix = Prefix + } + k.Masked = prefix + "…" + k.Secret[len(k.Secret)-6:] + } + k.Secret = "" + } + return keys, nil +} + +// Export returns credentials only for the encrypted backup bundle. +func Export() ([]Key, error) { + mu.Lock() + defer mu.Unlock() + return load() +} + +// Restore replaces the gateway credentials when restoring their settings. +func Restore(keys []Key) error { + mu.Lock() + defer mu.Unlock() + return save(slices.Clone(keys)) +} + +func random(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +type Change struct { + Key string `json:"key"` + Name string `json:"name"` +} + +// Update writes the named key store atomically. +func Update(action string, in Change) (string, error) { + mu.Lock() + defer mu.Unlock() + keys, err := load() + if err != nil { + return "", err + } + name := strings.TrimSpace(in.Name) + if action == "add-key" || action == "rename-key" { + if name == "" || utf8.RuneCountInString(name) > 120 { + return "", errors.New("Use a name between 1 and 120 characters") + } + } + var secret string + var mirror *Key + if action == "add-key" { + id, err := random(12) + if err != nil { + return "", err + } + token, err := random(24) + if err != nil { + return "", err + } + secret = Prefix + token + keys = append(keys, Key{ID: id, Name: name, Secret: secret}) + } else { + i := slices.IndexFunc(keys, func(k Key) bool { return k.ID == in.Key }) + if i < 0 { + return "", errors.New("Key not found") + } + defaultKey := keys[i].LAN || keys[i].ID == settings.Load().LANKeyID + if defaultKey { + keys[i].LAN = true + } + switch action { + case "rotate-key": + token, err := random(24) + if err != nil { + return "", err + } + secret = Prefix + token + keys[i].Secret = secret + case "rename-key": + keys[i].Name = name + case "on-key", "off-key": + keys[i].Off = action == "off-key" + case "remove-key": + if defaultKey { + k := keys[i] + k.Off = true + mirror = &k + } + keys = slices.Delete(keys, i, i+1) + case "copy-key": + return keys[i].Secret, nil + default: + return "", fmt.Errorf("unknown key action %q", action) + } + if defaultKey && action != "remove-key" && action != "rename-key" { + mirror = &keys[i] + } + } + if mirror != nil { + s := settings.Load() + if err := setLegacyMirror(&s, *mirror); err != nil { + return "", err + } + // Revoke the old-version mirror before changing the active key store. + if err := settings.Save(s); err != nil { + return "", fmt.Errorf("Cannot change the default gateway key without updating settings.json: older Magpie versions may still accept its old credential. Make the settings file writable and retry; the key is unchanged: %w", err) + } + } + if err := save(keys); err != nil { + return "", err + } + return secret, nil +} + +func setLegacyMirror(s *settings.Settings, k Key) error { + s.LANKeyID = k.ID + if !k.Off { + s.LANKey = k.Secret + return nil + } + token, err := random(24) + if err != nil { + return err + } + s.LANKey = revokedLANPrefix + token + return nil +} + +// Authenticate reloads the store so revocation takes effect in running gateways. +func Authenticate(secret string) (Identity, bool) { + mu.Lock() + defer mu.Unlock() + keys, err := load() + if err != nil || secret == "" { + return Identity{}, false + } + for _, k := range keys { + if subtle.ConstantTimeCompare([]byte(secret), []byte(k.Secret)) == 1 { + if k.Off { + return Identity{}, false + } + return Identity{k.ID, k.Name}, true + } + } + s := settings.Load() + // A read-only config directory may prevent the first store write. Never + // fall back after either durable marker exists, or to a revoked mirror. + if s.LAN && s.LANKeyID == "" && s.LANKey != "" && !strings.HasPrefix(s.LANKey, revokedLANPrefix) && !slices.ContainsFunc(keys, func(k Key) bool { return k.LAN }) && subtle.ConstantTimeCompare([]byte(secret), []byte(s.LANKey)) == 1 { + k := legacyLANKey(s.LANKey) + return Identity{k.ID, k.Name}, true + } + return Identity{}, false +} + +func legacyLANKey(secret string) Key { + // The fallback and eventual persisted key must share their usage identity. + sum := sha256.Sum256([]byte(secret)) + return Key{ID: "lan-" + hex.EncodeToString(sum[:12]), Name: "Magpie", LAN: true, Secret: secret} +} + +// MigrateLegacyLANKey makes the old Settings key a normal, revocable caller +// key. Keep LANKey for older Magpie versions; LANKeyID marks a completed +// migration so the retained credential cannot resurrect a removed key. +func MigrateLegacyLANKey() error { + mu.Lock() + defer mu.Unlock() + return migrateLegacyLANKey() +} + +func MigrateLegacyLANKeyBestEffort() { + if err := MigrateLegacyLANKey(); err != nil { + log.Printf("magpie: could not migrate LAN key: %v", err) + } +} + +func migrateLegacyLANKey() error { + s := settings.Load() + if s.LANKey == "" || s.LANKeyID != "" || strings.HasPrefix(s.LANKey, revokedLANPrefix) { + return nil + } + keys, err := load() + if err != nil { + return err + } + if slices.ContainsFunc(keys, func(k Key) bool { return k.LAN }) { + return nil // the key-store write succeeded, even if settings were read-only + } + i := slices.IndexFunc(keys, func(k Key) bool { + return subtle.ConstantTimeCompare([]byte(k.Secret), []byte(s.LANKey)) == 1 + }) + if i < 0 { + keys = append(keys, legacyLANKey(s.LANKey)) + i = len(keys) - 1 + } + keys[i].LAN = true + if err := save(keys); err != nil { + return err + } + s.LANKeyID = keys[i].ID + return settings.Save(s) +} + +// ConfigureLAN starts sharing with a named key. Rotation preserves the key's +// identity, name and enabled state. +func ConfigureLAN(on, rotate bool) error { + mu.Lock() + defer mu.Unlock() + if err := migrateLegacyLANKey(); err != nil { + return err + } + s := settings.Load() + if on { + keys, err := load() + if err != nil { + return err + } + i := slices.IndexFunc(keys, func(k Key) bool { return k.ID == s.LANKeyID || k.LAN }) + if i < 0 || rotate { + token, err := random(24) + if err != nil { + return err + } + if i < 0 { + id, err := random(12) + if err != nil { + return err + } + keys = append(keys, Key{ID: id, Name: "Magpie", LAN: true}) + i = len(keys) - 1 + } + keys[i].Secret = Prefix + token + if err := save(keys); err != nil { + return err + } + s.LANKeyID = keys[i].ID + } + if err := setLegacyMirror(&s, keys[i]); err != nil { + return err + } + } + s.LAN = on + return settings.Save(s) +} + +func save(keys []Key) error { + b, err := json.MarshalIndent(keys, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(Path()), 0o755); err != nil { + return err + } + f, err := os.OpenFile(Path(), os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Chmod(Path(), 0o600); err != nil { + return err + } + return edit.WriteAtomic(Path(), append(b, '\n')) +} diff --git a/internal/access/access_test.go b/internal/access/access_test.go new file mode 100644 index 00000000..4debd46b --- /dev/null +++ b/internal/access/access_test.go @@ -0,0 +1,302 @@ +package access + +import ( + "encoding/json" + "os" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/settings" +) + +func TestNamedKeys(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if _, err := Update("add-key", Change{Name: " "}); err == nil { + t.Fatal("accepted empty name") + } + keys, err := List() + if err != nil || len(keys) != 0 { + t.Fatal(keys, err) + } + secret, err := Update("add-key", Change{Name: "Laptop"}) + if err != nil { + t.Fatal(err) + } + keys, err = List() + if err != nil || len(keys) != 1 || keys[0].Name != "Laptop" || keys[0].Secret != "" || !strings.HasPrefix(keys[0].Masked, Prefix+"…") { + t.Fatal(keys, err) + } + id := keys[0].ID + if who, ok := Authenticate(secret); !ok || who.KeyID != id || who.KeyName != "Laptop" { + t.Fatal(who, ok) + } + if _, ok := Authenticate(secret + "x"); ok { + t.Fatal("accepted invalid key") + } + for _, step := range []struct { + action, name string + valid bool + }{ + {"rename-key", "Travel", true}, {"off-key", "", false}, {"on-key", "", true}, + } { + if _, err := Update(step.action, Change{Key: id, Name: step.name}); err != nil { + t.Fatal(err) + } + if who, ok := Authenticate(secret); ok != step.valid || (ok && who.KeyName != "Travel") { + t.Fatal(step.action, who, ok) + } + } + if got, err := Update("copy-key", Change{Key: id}); err != nil || got != secret { + t.Fatal("copy", err) + } + st, err := os.Stat(Path()) + if err != nil || st.Mode().Perm() != 0o600 { + t.Fatal("permissions", err) + } + var persisted []Key + b, _ := os.ReadFile(Path()) + if err := json.Unmarshal(b, &persisted); err != nil || len(persisted) != 1 || persisted[0].ID != id || persisted[0].Secret != secret { + t.Fatal("key was not persisted", err) + } + if _, err := Update("remove-key", Change{Key: id}); err != nil { + t.Fatal(err) + } + if _, ok := Authenticate(secret); ok { + t.Fatal("removed key works") + } +} + +func TestConcurrentKeyCreationAndCorruptStore(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var wg sync.WaitGroup + for i := 0; i < 12; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := Update("add-key", Change{Name: "Key"}); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + keys, _ := List() + if len(keys) != 12 { + t.Fatal("lost concurrent changes") + } + secret, _ := Update("copy-key", Change{Key: keys[0].ID}) + os.WriteFile(Path(), []byte("broken"), 0o600) + if _, ok := Authenticate(secret); ok { + t.Fatal("corrupt store accepted a key") + } + if _, err := Update("add-key", Change{Name: "New"}); err == nil { + t.Fatal("overwrote corrupt store") + } +} + +func TestLegacyLANKeyMigrationIsIdempotentAndRevocable(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := settings.Load() + s.LAN, s.LANKey = true, "sk-magpie-legacy-lan-secret" + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + keys, err := List() + if err != nil || len(keys) != 1 || keys[0].Name != "Magpie" || !strings.HasPrefix(keys[0].Masked, "sk-magpie-…") { + t.Fatal(keys, err) + } + if settings.Load().LANKey != s.LANKey || !settings.Load().LAN { + t.Fatal("migration broke older Magpie's sharing settings") + } + if who, ok := Authenticate(s.LANKey); !ok || who.KeyID != keys[0].ID { + t.Fatal(who, ok) + } + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + keys, _ = List() + if len(keys) != 1 { + t.Fatal("duplicate migration", keys) + } + if _, err := Update("off-key", Change{Key: keys[0].ID}); err != nil { + t.Fatal(err) + } + if _, ok := Authenticate(s.LANKey); ok { + t.Fatal("disabled LAN key still works") + } + if _, err := Update("remove-key", Change{Key: keys[0].ID}); err != nil { + t.Fatal(err) + } + if _, ok := Authenticate(s.LANKey); ok { + t.Fatal("removed LAN key still works") + } + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + if keys, _ := List(); len(keys) != 0 { + t.Fatal("retained legacy credential resurrected a removed key", keys) + } +} + +func TestLegacyLANKeyMigrationPreservesNamedKeys(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + secret, err := Update("add-key", Change{Name: "Client"}) + if err != nil { + t.Fatal(err) + } + keys, _ := List() + id := keys[0].ID + s := settings.Load() + s.LANKey = "sk-magpie-legacy" + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + keys, _ = List() + if len(keys) != 2 || keys[0].ID != id || keys[1].Name != "Magpie" { + t.Fatal(keys) + } + if _, ok := Authenticate(secret); !ok { + t.Fatal("lost named key") + } + if _, ok := Authenticate(s.LANKey); !ok { + t.Fatal("lost LAN key") + } +} + +func TestLegacyLANKeyMigrationRetryAndFailure(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := settings.Load() + s.LANKey = "sk-magpie-test-legacy-lan" + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + // The key write succeeded but saving the migration marker was interrupted. + mu.Lock() + err := save([]Key{{ID: "migrated", Name: "Remote", Off: true, Secret: s.LANKey}}) + mu.Unlock() + if err != nil { + t.Fatal(err) + } + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + keys, err := List() + if err != nil || len(keys) != 1 || keys[0].ID != "migrated" || !keys[0].Off || settings.Load().LANKeyID != "migrated" { + t.Fatal("retry duplicated or re-enabled a key", keys, err) + } + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(Path(), []byte("broken"), 0o600); err != nil { + t.Fatal(err) + } + if err := MigrateLegacyLANKey(); err == nil { + t.Fatal("migration accepted a corrupt store") + } + if settings.Load().LANKey != s.LANKey { + t.Fatal("failed migration cleared the old credential") + } +} + +func TestNamedLANKeyKeepsItsName(t *testing.T) { + for _, name := range []string{"Local network", "Magpie", "Desk"} { + t.Run(name, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := settings.Load() + s.LANKeyID = "lan" + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + original := []Key{{ID: "lan", Name: name, Off: true, Secret: Prefix + "original"}, + {ID: "other", Name: "Other", Secret: Prefix + "other"}, + {ID: "named", Name: "Local network", Secret: Prefix + "named"}} + if err := save(original); err != nil { + t.Fatal(err) + } + for range 2 { + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + } + keys, err := load() + if err != nil || len(keys) != 3 || keys[0] != original[0] || keys[1] != original[1] || keys[2] != original[2] { + t.Fatal("migration changed an existing named key", keys, err) + } + }) + } +} + +func TestCallerKeyRotationPreservesIdentityAndState(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + old, err := Update("add-key", Change{Name: "Desk"}) + if err != nil { + t.Fatal(err) + } + other, err := Update("add-key", Change{Name: "Other"}) + if err != nil { + t.Fatal(err) + } + keys, _ := List() + id := keys[0].ID + for _, off := range []bool{false, true} { + if off { + if _, err := Update("off-key", Change{Key: id}); err != nil { + t.Fatal(err) + } + } + secret, err := Update("rotate-key", Change{Key: id}) + if err != nil || secret == old || !strings.HasPrefix(secret, Prefix) { + t.Fatal("key was not rotated", err) + } + if _, ok := Authenticate(old); ok { + t.Fatal("old key still authenticates") + } + if who, ok := Authenticate(secret); ok == off || (!off && (who.KeyID != id || who.KeyName != "Desk")) { + t.Fatal("rotation changed identity or enabled state", who, ok) + } + keys, _ = List() + if keys[0].ID != id || keys[0].Name != "Desk" || keys[0].Off != off || keys[0].Secret != "" { + t.Fatal(keys) + } + if _, ok := Authenticate(other); !ok { + t.Fatal("rotation revoked another key") + } + old = secret + } + if _, err := Update("rotate-key", Change{Key: "missing"}); err == nil { + t.Fatal("rotated a missing key") + } +} + +func TestLANRotationKeepsOlderMagpieWorking(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + before := settings.Load() + if before.LANKey == "" { + t.Fatal("older Magpie has no sharing key") + } + next, err := Update("rotate-key", Change{Key: before.LANKeyID}) + if err != nil { + t.Fatal(err) + } + if settings.Load().LANKey != next { + t.Fatal("rotation left the old version with a stale key") + } + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + if keys, _ := List(); len(keys) != 1 || keys[0].ID != before.LANKeyID { + t.Fatal("rotation duplicated the default key", keys) + } + if _, ok := Authenticate(before.LANKey); ok { + t.Fatal("old key resurrected") + } +} diff --git a/internal/access/legacy_fallback_test.go b/internal/access/legacy_fallback_test.go new file mode 100644 index 00000000..533a13a6 --- /dev/null +++ b/internal/access/legacy_fallback_test.go @@ -0,0 +1,101 @@ +package access + +import ( + "errors" + "os" + "testing" + + "github.com/yetone/magpie/internal/settings" +) + +func TestLegacyLANFallbackBeforeMigration(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := settings.Settings{LAN: true, LANKey: "sk-magpie-old-client"} + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + before, ok := Authenticate(s.LANKey) + if !ok || before.KeyID == "" || before.KeyName != "Magpie" { + t.Fatal("unmigrated legacy key lost access", before, ok) + } + if _, err := os.Stat(Path()); !errors.Is(err, os.ErrNotExist) { + t.Fatal("authentication must not create a key store", err) + } + for _, secret := range []string{"", "wrong", "magpie", s.LANKey + "x"} { + if _, ok := Authenticate(secret); ok { + t.Fatal("legacy fallback accepted another token") + } + } + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + if after, ok := Authenticate(s.LANKey); !ok || after != before { + t.Fatal("migration split the legacy caller's usage identity", before, after, ok) + } +} + +func TestLegacyLANFallbackDoesNotBypassRevocation(t *testing.T) { + for _, tc := range []struct { + name string + cfg settings.Settings + keys []Key + }{ + {"not-shared", settings.Settings{LANKey: "legacy"}, nil}, + {"completed", settings.Settings{LAN: true, LANKey: "legacy", LANKeyID: "removed"}, nil}, + {"revoked-mirror", settings.Settings{LAN: true, LANKey: revokedLANPrefix + "old"}, nil}, + {"disabled", settings.Settings{LAN: true, LANKey: "legacy"}, []Key{{ID: "default", LAN: true, Secret: "legacy", Off: true}}}, + {"rotated", settings.Settings{LAN: true, LANKey: "legacy"}, []Key{{ID: "default", LAN: true, Secret: "new"}}}, + {"disabled-matching-key", settings.Settings{LAN: true, LANKey: "legacy"}, []Key{{ID: "other", Secret: "legacy", Off: true}}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := settings.Save(tc.cfg); err != nil { + t.Fatal(err) + } + if tc.keys != nil { + if err := Restore(tc.keys); err != nil { + t.Fatal(err) + } + } + if _, ok := Authenticate(tc.cfg.LANKey); ok { + t.Fatal("fallback accepted a revoked or inactive legacy credential") + } + }) + } +} + +func TestLegacyLANFallbackWithReadOnlyDirectory(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := settings.Settings{LAN: true, LANKey: "sk-magpie-readonly"} + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := os.Chmod(settings.Dir(), 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(settings.Dir(), 0o700) }) + if err := MigrateLegacyLANKey(); err == nil { + t.Skip("configuration directory remains writable") + } else if !errors.Is(err, os.ErrPermission) { + t.Fatal(err) + } + for range 3 { + if who, ok := Authenticate(s.LANKey); !ok || who.KeyID == "" || who.KeyName != "Magpie" { + t.Fatal("failed key-store creation broke legacy access", who, ok) + } + } +} + +func TestLegacyLANFallbackRefusesCorruptStore(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := settings.Settings{LAN: true, LANKey: "legacy"} + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(Path(), []byte("broken"), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := Authenticate(s.LANKey); ok { + t.Fatal("an unreadable revocation state must not enable legacy fallback") + } +} diff --git a/internal/access/legacy_revocation_test.go b/internal/access/legacy_revocation_test.go new file mode 100644 index 00000000..0f264875 --- /dev/null +++ b/internal/access/legacy_revocation_test.go @@ -0,0 +1,133 @@ +package access + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/yetone/magpie/internal/settings" +) + +func TestDefaultKeyRevocationSurvivesDowngrade(t *testing.T) { + for _, action := range []string{"off-key", "remove-key"} { + t.Run(action, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + before := settings.Load() + if _, err := Update(action, Change{Key: before.LANKeyID}); err != nil { + t.Fatal(err) + } + after := settings.Load() + if after.LANKey == "" || after.LANKey == before.LANKey { + t.Fatal("older Magpie would accept the revoked credential") + } + if _, ok := Authenticate(after.LANKey); ok { + t.Fatal("legacy replacement is an active gateway key") + } + // An older binary saves settings without the new migration marker. + after.LANKeyID = "" + if err := settings.Save(after); err != nil { + t.Fatal(err) + } + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + keys, err := List() + if err != nil || (action == "remove-key" && len(keys) != 0) || (action == "off-key" && (len(keys) != 1 || !keys[0].Off)) { + t.Fatal("downgrade resurrected a key", keys, err) + } + if action == "off-key" { + if _, err := Update("rotate-key", Change{Key: before.LANKeyID}); err != nil { + t.Fatal(err) + } + current, _ := Update("copy-key", Change{Key: before.LANKeyID}) + if settings.Load().LANKey == current { + t.Fatal("rotation re-enabled the disabled legacy credential") + } + if _, err := Update("on-key", Change{Key: before.LANKeyID}); err != nil { + t.Fatal(err) + } + if settings.Load().LANKey != current { + t.Fatal("re-enabling did not restore the legacy mirror") + } + } + }) + } +} + +func TestReadOnlyMigrationDoesNotRetryCompletedKeyWrite(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := settings.Settings{LAN: true, LANKey: "sk-magpie-fixture-legacy"} + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := os.Chmod(settings.Path(), 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(settings.Path(), 0o600) }) + if err := settings.Save(s); err == nil { + t.Skip("settings.json remains writable") + } + if err := MigrateLegacyLANKey(); err == nil { + t.Fatal("initial marker write unexpectedly succeeded") + } + for range 3 { + if err := MigrateLegacyLANKey(); err != nil { + t.Fatal("completed key migration retried read-only settings", err) + } + } + if who, ok := Authenticate(s.LANKey); !ok || who.KeyName != "Magpie" { + t.Fatal("legacy key was not usable after partial migration", who, ok) + } +} + +func TestReadOnlyDefaultKeyMutationExplainsLegacyMirror(t *testing.T) { + for _, action := range []string{"rotate-key", "off-key", "remove-key"} { + t.Run(action, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + s := settings.Load() + independent, err := Update("add-key", Change{Name: "Laptop"}) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(settings.Path(), 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(settings.Path(), 0o600) }) + if err := settings.Save(s); err == nil { + t.Skip("settings.json remains writable") + } + before, err := os.ReadFile(Path()) + if err != nil { + t.Fatal(err) + } + _, err = Update(action, Change{Key: s.LANKeyID}) + if !errors.Is(err, os.ErrPermission) || !strings.Contains(err.Error(), "older Magpie") || !strings.Contains(err.Error(), "writable") || !strings.Contains(err.Error(), "unchanged") { + t.Fatal("default-key error does not explain the recovery or downgrade risk", err) + } + after, err := os.ReadFile(Path()) + if err != nil || string(after) != string(before) { + t.Fatal("failed legacy mirror write changed active keys", err) + } + if _, ok := Authenticate(s.LANKey); !ok { + t.Fatal("operation reported failure but revoked the default key") + } + who, ok := Authenticate(independent) + if !ok { + t.Fatal("independent key is missing") + } + if _, err := Update(action, Change{Key: who.KeyID}); err != nil { + t.Fatal("read-only settings blocked an independent key", err) + } + if _, ok := Authenticate(independent); ok { + t.Fatal("independent key kept its old credential") + } + }) + } +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go index f6704535..0794f4cc 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -28,6 +28,7 @@ import ( "strings" "time" + "github.com/yetone/magpie/internal/access" "github.com/yetone/magpie/internal/agent" "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/library" @@ -46,17 +47,18 @@ const ( // Bundle is what a backup holds. type Bundle struct { - Version int `json:"version"` - Created time.Time `json:"created"` - App string `json:"app,omitempty"` // the magpie that made it - Keys bool `json:"keys"` // whether the providers carry their keys - Providers []provider.Provider `json:"providers"` - Icons map[string][]byte `json:"icons,omitempty"` // pictures picked for providers, by file name - Groups []provider.Group `json:"groups,omitempty"` // the user's model groups - Settings *settings.Settings `json:"settings,omitempty"` - Profiles map[string]profile.Profile `json:"profiles,omitempty"` - Agents map[string]string `json:"agents,omitempty"` // every agent's fields as they are now - Library *library.Bundle `json:"library,omitempty"` // nil from a magpie before it, or with none + Version int `json:"version"` + Created time.Time `json:"created"` + App string `json:"app,omitempty"` // the magpie that made it + Keys bool `json:"keys"` // whether credentials are included + Providers []provider.Provider `json:"providers"` + Icons map[string][]byte `json:"icons,omitempty"` // pictures picked for providers, by file name + Groups []provider.Group `json:"groups,omitempty"` // the user's model groups + Settings *settings.Settings `json:"settings,omitempty"` + GatewayKeys *[]access.Key `json:"gatewayKeys,omitempty"` + Profiles map[string]profile.Profile `json:"profiles,omitempty"` + Agents map[string]string `json:"agents,omitempty"` // every agent's fields as they are now + Library *library.Bundle `json:"library,omitempty"` // nil from a magpie before it, or with none // Searches are the web search APIs (#419), their keys with the // providers'; nil from a magpie before them. Searches *[]provider.SearchAPI `json:"searches,omitempty"` @@ -90,6 +92,18 @@ func Collect(keys bool, app string) (Bundle, error) { if b.Groups, err = provider.StoredGroups(); err != nil { return b, err } + if keys { + access.MigrateLegacyLANKeyBestEffort() + if _, err := os.Stat(access.Path()); err == nil { + gatewayKeys, err := access.Export() + if err != nil { + return b, err + } + b.GatewayKeys = &gatewayKeys + } else if !errors.Is(err, os.ErrNotExist) { + return b, err + } + } for _, p := range stored { if !keys { p = withoutKeys(p) @@ -116,6 +130,9 @@ func Collect(keys bool, app string) (Bundle, error) { b.Searches = &searches if _, err := os.Stat(settings.Path()); err == nil { s := settings.Load() + if !keys { + s.LANKey, s.LANKeyID = "", "" + } b.Settings = &s } ps, err := profile.Load() @@ -285,13 +302,44 @@ func Restore(b Bundle, parts Parts) (Result, error) { } } } - if parts.Settings && b.Settings != nil { + if parts.Settings && (b.Settings != nil || b.GatewayKeys != nil) { // the window's size and the proxy are this machine's own - s, cur := *b.Settings, settings.Load() + cur := settings.Load() + s := cur + if b.Settings != nil { + s = *b.Settings + } s.Window, s.Proxy, s.Dock, s.DockWindow = cur.Window, cur.Proxy, cur.Dock, cur.DockWindow + if !b.Keys { + s.LANKey, s.LANKeyID = cur.LANKey, cur.LANKeyID + } else if b.GatewayKeys == nil { + // An older backup may carry a marker without its named-key store. + s.LANKeyID = "" + keys, err := access.List() + if err != nil { + return r, err + } + if slices.ContainsFunc(keys, func(k access.Key) bool { return k.LAN }) { + // Its missing store must not detach this machine's default key. + s.LANKey, s.LANKeyID = cur.LANKey, cur.LANKeyID + } + } if err := settings.Save(s); err != nil { return r, err } + if b.Keys && b.GatewayKeys != nil { + if err := access.Restore(*b.GatewayKeys); err != nil { + // Settings must be writable before replacing credentials. If the + // store refuses the write, restore their original association. + if rollback := settings.Save(cur); rollback != nil { + return r, errors.Join(err, fmt.Errorf("could not restore original settings after gateway-key restore failed: %w", rollback)) + } + return r, err + } + } + if b.Keys && b.GatewayKeys == nil { + access.MigrateLegacyLANKeyBestEffort() + } r.Settings = true } if parts.Profiles && len(b.Profiles) > 0 { diff --git a/internal/backup/caller_keys_test.go b/internal/backup/caller_keys_test.go new file mode 100644 index 00000000..bd0f17fc --- /dev/null +++ b/internal/backup/caller_keys_test.go @@ -0,0 +1,212 @@ +package backup + +import ( + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/settings" +) + +func TestGatewayKeysBackupRoundTrip(t *testing.T) { + home(t) + if err := access.ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + defaultKey := settings.Load() + client, err := access.Update("add-key", access.Change{Name: "Laptop"}) + if err != nil { + t.Fatal(err) + } + keys, _ := access.List() + if _, err := access.Update("off-key", access.Change{Key: defaultKey.LANKeyID}); err != nil { + t.Fatal(err) + } + b, err := Collect(true, "test") + if err != nil { + t.Fatal(err) + } + data, err := Seal(b, "gateway passphrase") + if err != nil || strings.Contains(string(data), client) || strings.Contains(string(data), defaultKey.LANKey) { + t.Fatal("backup did not seal gateway credentials", err) + } + home(t) + b, err = Open(data, "gateway passphrase") + if err != nil { + t.Fatal(err) + } + if _, err := Restore(b, Parts{Settings: true}); err != nil { + t.Fatal(err) + } + got, err := access.List() + if err != nil || len(got) != len(keys) || got[0].ID != defaultKey.LANKeyID || !got[0].Off || got[1].ID != keys[1].ID || got[1].Name != "Laptop" { + t.Fatal("named key identity/state lost during restore", got, err) + } + if who, ok := access.Authenticate(client); !ok || who.KeyID != keys[1].ID { + t.Fatal("restored client cannot authenticate", who, ok) + } + if _, ok := access.Authenticate(defaultKey.LANKey); ok { + t.Fatal("restore resurrected the disabled default key") + } + if err := access.MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + if got, _ := access.List(); len(got) != len(keys) { + t.Fatal("restore imported a duplicate default key", got) + } + if _, err := access.Update("on-key", access.Change{Key: defaultKey.LANKeyID}); err != nil { + t.Fatal(err) + } + if settings.Load().LANKey != defaultKey.LANKey { + t.Fatal("restored default key lost its legacy link") + } + info, err := os.Stat(access.Path()) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatal("restored key-store permissions", info, err) + } +} + +func TestGatewayKeysWithoutSecretsOrSettingsRestore(t *testing.T) { + home(t) + if err := access.ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + secret := settings.Load().LANKey + full, err := Collect(true, "test") + if err != nil { + t.Fatal(err) + } + without, err := Collect(false, "test") + if err != nil { + t.Fatal(err) + } + plain, _ := json.Marshal(without) + if strings.Contains(string(plain), secret) || without.Settings.LANKey != "" || without.Settings.LANKeyID != "" { + t.Fatal("--no-keys backup contains a gateway credential") + } + home(t) + if err := access.ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + current := settings.Load() + if _, err := Restore(full, Parts{}); err != nil { + t.Fatal(err) + } + if _, err := Restore(without, Parts{Settings: true}); err != nil { + t.Fatal(err) + } + if got := settings.Load(); got.LANKey != current.LANKey || got.LANKeyID != current.LANKeyID { + t.Fatal("keyless restore replaced this machine's LAN credential") + } + if _, ok := access.Authenticate(current.LANKey); !ok { + t.Fatal("restore without gateway keys revoked this machine's key") + } +} + +func TestOlderBackupRecoversLegacyGatewayCredential(t *testing.T) { + home(t) + b := Bundle{Version: 1, Keys: true, Settings: &settings.Settings{LAN: true, LANKey: "{{API_KEY_88kqppzx}}", LANKeyID: "id-from-incomplete-backup"}} + if _, err := Restore(b, Parts{Settings: true}); err != nil { + t.Fatal(err) + } + if who, ok := access.Authenticate(b.Settings.LANKey); !ok || who.KeyName != "Magpie" { + t.Fatal("old backup's migration marker lost its credential", who, ok) + } +} + +func TestOlderBackupKeepsExistingGatewayKeyStore(t *testing.T) { + home(t) + if err := access.ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + cur := settings.Load() + b := Bundle{Version: 1, Keys: true, Settings: &settings.Settings{LAN: true, LANKey: "fixture-older-backup-key", LANKeyID: "old-marker"}} + if _, err := Restore(b, Parts{Settings: true}); err != nil { + t.Fatal(err) + } + if got := settings.Load(); got.LANKey != cur.LANKey || got.LANKeyID != cur.LANKeyID { + t.Fatal("backup without a key store detached the existing default key") + } + if _, ok := access.Authenticate(cur.LANKey); !ok { + t.Fatal("backup without a key store revoked the existing gateway key") + } +} + +func TestGatewayKeysBackupWithoutSettingsFile(t *testing.T) { + home(t) + secret, err := access.Update("add-key", access.Change{Name: "CLI client"}) + if err != nil { + t.Fatal(err) + } + b, err := Collect(true, "test") + if err != nil || b.Settings != nil { + t.Fatal("CLI-only key fixture unexpectedly has settings", err) + } + home(t) + if _, err := Restore(b, Parts{Settings: true}); err != nil { + t.Fatal(err) + } + if who, ok := access.Authenticate(secret); !ok || who.KeyName != "CLI client" { + t.Fatal("standalone CLI key was not restored", who, ok) + } +} + +func TestGatewayKeyRestoreFailureKeepsSettingsAndKeys(t *testing.T) { + for _, failure := range []string{"invalid-settings", "readonly-settings", "readonly-store"} { + t.Run(failure, func(t *testing.T) { + home(t) + if err := access.ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + cur := settings.Load() + before, err := os.ReadFile(access.Path()) + if err != nil { + t.Fatal(err) + } + incoming := cur + incoming.Theme, incoming.LANKeyID, incoming.LANKey = "dark", "restored", "sk-magpie-restored" + keys := []access.Key{{ID: incoming.LANKeyID, Name: "Restored", Secret: incoming.LANKey, LAN: true}} + if failure == "invalid-settings" { + incoming.Theme = "invalid-theme" + } else { + path := settings.Path() + if failure == "readonly-store" { + path = access.Path() + } + if err := os.Chmod(path, 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o600) }) + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err == nil { + f.Close() + t.Skip("file remains writable") + } + if !errors.Is(err, os.ErrPermission) { + t.Fatal(err) + } + } + r, err := Restore(Bundle{Version: 1, Keys: true, Settings: &incoming, GatewayKeys: &keys}, Parts{Settings: true}) + if err == nil || r.Settings { + t.Fatal("failed restore reported success", r, err) + } + if failure != "invalid-settings" && !errors.Is(err, os.ErrPermission) { + t.Fatal("restore lost permission error", err) + } + after, err := os.ReadFile(access.Path()) + if err != nil || string(after) != string(before) || !reflect.DeepEqual(settings.Load(), cur) { + t.Fatal("failed restore left settings and keys inconsistent", err) + } + if _, ok := access.Authenticate(cur.LANKey); !ok { + t.Fatal("failed restore revoked the current key") + } + if _, ok := access.Authenticate(incoming.LANKey); ok { + t.Fatal("failed restore activated the incoming key") + } + }) + } +} diff --git a/internal/gateway/caller_regression_test.go b/internal/gateway/caller_regression_test.go new file mode 100644 index 00000000..fcd16aff --- /dev/null +++ b/internal/gateway/caller_regression_test.go @@ -0,0 +1,306 @@ +package gateway + +import ( + "bytes" + "context" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/usage" +) + +func TestLoopbackRemainsPermissive(t *testing.T) { + fresh(t) + keys, secrets := newCaller(t, "Desk") + access.Update("off-key", access.Change{Key: keys[0].ID}) + for _, shared := range []bool{false, true} { + if err := settings.Save(settings.Settings{LAN: shared}); err != nil { + t.Fatal(err) + } + for _, token := range []string{"", "anything", Token, TokenFor("alma"), "sk-magpie-stale", secrets[0]} { + for _, addr := range []string{"127.0.0.1:5000", "[::1]:5000"} { + r := httptest.NewRequest("GET", "/v1/models", nil) + r.RemoteAddr = addr + r.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + lanGuard(New().Handler()).ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("shared=%v %s %q: %d", shared, addr, token, w.Code) + } + } + } + } +} + +func TestExplicitOpenGatewayAcceptsAllRemoteTokens(t *testing.T) { + fresh(t) + t.Setenv("MAGPIE_ADDR", "0.0.0.0:3425") + if _, err := access.Update("add-key", access.Change{Name: "Unused"}); err != nil { + t.Fatal(err) + } + keys, _ := access.List() + secret, _ := access.Update("copy-key", access.Change{Key: keys[0].ID}) + for _, token := range []string{"", "anything", "sk-magpie-stale", secret} { + for _, header := range []string{"Authorization", "x-api-key", "query"} { + r := httptest.NewRequest("GET", "/v1/models", nil) + r.RemoteAddr = "192.168.1.9:5000" + if header == "query" { + r.URL.RawQuery = "key=" + token + } else if header == "Authorization" { + r.Header.Set(header, "Bearer "+token) + } else { + r.Header.Set(header, token) + } + w := httptest.NewRecorder() + lanGuard(New().Handler()).ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("explicit open gateway rejected %s %q: %d", header, token, w.Code) + } + } + } +} + +func TestMigrationFailureDoesNotStopGateway(t *testing.T) { + fresh(t) + t.Setenv("MAGPIE_ADDR", "127.0.0.1:0") + if err := settings.Save(settings.Settings{LAN: true, LANKey: "sk-magpie-old"}); err != nil { + t.Fatal(err) + } + if err := os.Chmod(settings.Path(), 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(settings.Path(), 0o600) }) + if err := settings.Save(settings.Load()); err == nil { + t.Skip("settings.json remains writable on this platform") + } + var logs bytes.Buffer + old := log.Writer() + log.SetOutput(&logs) + defer log.SetOutput(old) + s := New() + if err := s.Relisten(); err != nil { + t.Fatal("migration stopped Relisten", err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- s.ListenAndServe(ctx) }() + defer func() { + cancel() + if err := <-done; err != nil { + t.Error(err) + } + if !strings.Contains(logs.String(), "migrat") { + t.Error("migration failure was not logged") + } + }() + deadline := time.Now().Add(3 * time.Second) + for { + s.lnMu.Lock() + ln := s.ln + s.lnMu.Unlock() + if ln != nil { + break + } + if time.Now().After(deadline) { + t.Fatal("gateway did not bind after failed migration") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestLegacyCallerWithReadOnlyConfiguration(t *testing.T) { + fresh(t) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer upstream" || r.URL.Query().Get("key") == "sk-magpie-readonly" { + t.Error("legacy caller credential leaked upstream") + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":30,"completion_tokens":5}}`) + })) + t.Cleanup(up.Close) + if err := provider.Save(provider.Provider{ID: "plan", Name: "Plan", Key: "upstream", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + s := settings.Settings{LAN: true, LANKey: "sk-magpie-readonly"} + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + // Existing logs remain appendable even if no new store can be created. + if err := os.WriteFile(usage.Path(), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(settings.Dir(), 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(settings.Dir(), 0o700) }) + if err := migrateLANKey(); err == nil { + t.Skip("configuration directory remains writable") + } else if !errors.Is(err, os.ErrPermission) { + t.Fatal(err) + } + h := lanGuard(New().Handler()) + call := func(secret, header string) int { + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq)) + r.RemoteAddr = "192.168.1.9:5000" + switch header { + case "Authorization": + r.Header.Set(header, "Bearer "+secret) + case "query": + r.URL.RawQuery = "key=" + secret + default: + r.Header.Set(header, secret) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code + } + for _, header := range []string{"Authorization", "x-api-key", "query"} { + if code := call(s.LANKey, header); code != http.StatusOK { + t.Fatal("unmigrated legacy client lost access", header, code) + } + for _, secret := range []string{"", "wrong", Token} { + if code := call(secret, header); code != http.StatusUnauthorized { + t.Fatal("fallback accepted another credential", header, code) + } + } + } + recs := usage.Load(time.Time{}) + if len(recs) != 3 || recs[0].CallerKeyID == "" { + t.Fatal("fallback attribution missing", recs) + } + for _, rec := range recs { + if rec.CallerKeyID != recs[0].CallerKeyID || rec.CallerKeyName != "Magpie" || rec.ProviderKeyID != provider.KeyID("upstream") { + t.Fatal("fallback attribution changed", rec) + } + } + if _, err := os.Stat(access.Path()); !errors.Is(err, os.ErrNotExist) { + t.Fatal("fallback wrote a key store", err) + } + log, err := os.ReadFile(usage.Path()) + if err != nil || strings.Contains(string(log), s.LANKey) { + t.Fatal("legacy credential in usage log", err) + } + if err := os.Chmod(settings.Dir(), 0o700); err != nil { + t.Fatal(err) + } + if err := migrateLANKey(); err != nil { + t.Fatal(err) + } + if code := call(s.LANKey, "Authorization"); code != 200 || lastUsage(t).CallerKeyID != recs[0].CallerKeyID { + t.Fatal("migration changed the fallback identity", code) + } + if _, err := access.Update("remove-key", access.Change{Key: recs[0].CallerKeyID}); err != nil { + t.Fatal(err) + } + if code := call(s.LANKey, "Authorization"); code != 401 { + t.Fatal("fallback resurrected a removed legacy key", code) + } +} + +func TestCallerUsageSystemOne(t *testing.T) { + fresh(t) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer upstream" { + t.Error("caller key reached System One upstream") + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"model":"jev","usage":{"input_tokens":120,"output_tokens":2}}`) + })) + defer up.Close() + if err := provider.Save(provider.Provider{ID: "jev", Name: "Jev", Key: "upstream", Decide: up.URL + "/v1"}); err != nil { + t.Fatal(err) + } + keys, secrets := newCaller(t, "System One client") + r := httptest.NewRequest("POST", "/v1/systemone", strings.NewReader(`{"model":"jev/jev-latest","state":{"message":"hi"},"questions":{}}`)) + r.Header.Set("Authorization", "Bearer "+secrets[0]) + w := httptest.NewRecorder() + s := New() + s.Handler().ServeHTTP(w, r) + if w.Code != 200 { + t.Fatal(w.Code, w.Body) + } + if rec := lastUsage(t); rec.RouteID == 0 || rec.RouteID != lastRoute(s).ID || rec.CallerKeyID != keys[0].ID || rec.CallerKeyName != "System One client" || rec.ProviderKeyID != provider.KeyID("upstream") || rec.Input != 120 { + t.Fatal(rec) + } +} + +func TestCallerUsageCodexOwnModel(t *testing.T) { + setup(t, provider.Chat, &fake{t: t}) + chatgpt(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer chatgpt-token" { + t.Error("Codex's own sign-in was changed") + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse(`data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":9,"output_tokens":2}}}`)) + }) + r := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"hi"}`)) + r.Header.Set("Authorization", "Bearer chatgpt-token") + r = r.WithContext(access.WithIdentity(r.Context(), access.Identity{KeyID: "desk", KeyName: "Desk"})) + w := httptest.NewRecorder() + s := New() + s.Handler().ServeHTTP(w, r) + if w.Code != 200 { + t.Fatal(w.Code, w.Body) + } + if rec := lastUsage(t); rec.RouteID == 0 || rec.RouteID != lastRoute(s).ID || rec.CallerKeyID != "desk" || rec.CallerKeyName != "Desk" || rec.Input != 9 || rec.Output != 2 { + t.Fatal(rec) + } +} + +func TestConcurrentCallerAttribution(t *testing.T) { + fresh(t) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer upstream" { + t.Error("caller credential leaked upstream") + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":30,"completion_tokens":5}}`) + })) + defer up.Close() + if err := provider.Save(provider.Provider{ID: "plan", Name: "Plan", Key: "upstream", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + keys, secrets := newCaller(t, "Desk", "Server") + if err := settings.Save(settings.Settings{LAN: true}); err != nil { + t.Fatal(err) + } + h := lanGuard(New().Handler()) + var wg sync.WaitGroup + for i := range 120 { + wg.Add(1) + go func() { + defer wg.Done() + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq)) + r.RemoteAddr = "192.168.1.9:5000" + r.Header.Set("Authorization", "Bearer "+secrets[i%len(secrets)]) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 { + t.Error(w.Code, w.Body) + } + }() + } + wg.Wait() + counts := map[string]int{} + for _, rec := range usage.Load(time.Time{}) { + counts[rec.CallerKeyID]++ + if rec.CallerKeyName != keys[0].Name && rec.CallerKeyName != keys[1].Name { + t.Error("lost caller name", rec) + } + } + if len(counts) != 2 || counts[keys[0].ID] != 60 || counts[keys[1].ID] != 60 { + t.Fatal("concurrent calls mixed identities", counts) + } +} diff --git a/internal/gateway/caller_usage_test.go b/internal/gateway/caller_usage_test.go new file mode 100644 index 00000000..1160e5ee --- /dev/null +++ b/internal/gateway/caller_usage_test.go @@ -0,0 +1,323 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/usage" +) + +func newCaller(t *testing.T, names ...string) ([]access.Key, []string) { + t.Helper() + s := settings.Load() + s.LAN = true + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + var secrets []string + for _, name := range names { + secret, err := access.Update("add-key", access.Change{Name: name}) + if err != nil { + t.Fatal(err) + } + secrets = append(secrets, secret) + } + keys, err := access.List() + if err != nil { + t.Fatal(err) + } + return keys[len(keys)-len(names):], secrets +} + +func TestCallerUsageAcrossKeys(t *testing.T) { + fresh(t) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer upstream-secret" { + t.Error("caller credential reached provider") + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", "req-caller") + io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":30,"completion_tokens":5}}`) + })) + defer up.Close() + if err := provider.Save(provider.Provider{ID: "plan", Name: "Plan", Key: "upstream-secret", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + keys, secrets := newCaller(t, "Laptop", "Server", "Work") + h := New().Handler() + call := func(secret, header string) int { + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq)) + if header == "Authorization" { + secret = "Bearer " + secret + } + r.Header.Set(header, secret) + r.Header.Set(SessionHeader, "override-session") + r.Header.Set("X-Session-Id", "native-session") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code + } + for i, secret := range secrets { + header := []string{"Authorization", "x-api-key", "x-goog-api-key"}[i] + if code := call(secret, header); code != 200 { + t.Fatal("request", code) + } + } + recs := usage.Load(time.Time{}) + if len(recs) != 3 || recs[0].CallerKeyID != keys[0].ID || recs[1].CallerKeyID != keys[1].ID || recs[2].CallerKeyName != "Work" { + t.Fatalf("attribution: %+v", recs) + } + for _, rec := range recs { + if rec.Input != 30 || rec.Output != 5 || rec.RequestID != "req-caller" || rec.Endpoint != "/v1/chat/completions" || rec.Session != "override-session" || rec.NativeSession != "native-session" { + t.Fatal(rec) + } + } + s := usage.Summarize(usage.All) + if len(s.CallerKeys) != 3 || s.Calls != 3 { + t.Fatalf("summary: %+v", s) + } + rows, totals, _ := usage.Ledger(usage.All, usage.Filter{CallerKey: keys[1].ID}) + if len(rows) != 1 || totals.Input != 30 || rows[0].CallerKeyName != "Server" { + t.Fatal(rows, totals) + } + access.Update("off-key", access.Change{Key: keys[0].ID}) + if code := call(secrets[0], "Authorization"); code != 401 { + t.Fatal("disabled key", code) + } + if code := call(secrets[2], "Authorization"); code != 200 { + t.Fatal("other key", code) + } + access.Update("remove-key", access.Change{Key: keys[2].ID}) + if code := call(secrets[2], "Authorization"); code != 401 { + t.Fatal("deleted key", code) + } + log, _ := os.ReadFile(usage.Path()) + for _, secret := range secrets { + if strings.Contains(string(log), secret) { + t.Fatal("caller credential in ledger") + } + } +} + +func TestRejectedRequestRetainsCallerIdentity(t *testing.T) { + fresh(t) + keys, secrets := newCaller(t, "Rejected client") + r := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(`{"model":"nothing/here","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}`)) + r.Header.Set("Authorization", "Bearer "+secrets[0]) + w := httptest.NewRecorder() + New().Handler().ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatal(w.Code, w.Body.String()) + } + rec := lastUsage(t) + if !rec.Rejected || rec.CallerKeyID != keys[0].ID || rec.CallerKeyName != "Rejected client" || rec.Endpoint != "/v1/messages" || rec.Error != "unknown model" || rec.ProviderKeyID != "" { + t.Fatalf("rejected request lost caller or failure metadata: %+v", rec) + } + page := usage.QueryPage(usage.All, usage.Filter{CallerKey: keys[0].ID}, 0, 100) + if page.Total != 1 || page.Sum.Calls != 0 || len(page.CallerKeys) != 0 || usage.Summarize(usage.All).Calls != 0 { + t.Fatalf("local rejection must stay visible without inflating usage: %+v", page) + } + log, err := os.ReadFile(usage.Path()) + if err != nil || strings.Contains(string(log), secrets[0]) { + t.Fatal("caller credential in rejected request log", err) + } +} + +func TestManagedLANGuard(t *testing.T) { + fresh(t) + keys, secrets := newCaller(t, "Remote") + shared := settings.Load() + shared.LAN = true + if err := settings.Save(shared); err != nil { + t.Fatal(err) + } + h := lanGuard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + who := access.Caller(r.Context()) + if who.KeyID != keys[0].ID || r.URL.Query().Get("key") != Token { + t.Fatal("query key or user not normalized", who) + } + })) + r := httptest.NewRequest("POST", "/v1beta/models/m:generateContent?key="+secrets[0], nil) + r.RemoteAddr = "192.168.1.9:5000" + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatal(w.Code) + } + access.Update("off-key", access.Change{Key: keys[0].ID}) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 401 { + t.Fatal("disabled remote key", w.Code) + } +} + +func TestMigratedLANKeyUsageAndRevocation(t *testing.T) { + fresh(t) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer upstream-secret" { + t.Error("caller credential reached provider") + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":30,"completion_tokens":5}}`) + })) + defer up.Close() + if err := provider.Save(provider.Provider{ID: "plan", Name: "Plan", Key: "upstream-secret", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + s := settings.Load() + s.LAN, s.LANKey = true, "sk-magpie-test-legacy-lan" + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if err := access.MigrateLegacyLANKey(); err != nil { + t.Fatal(err) + } + keys, err := access.List() + if err != nil || len(keys) != 1 { + t.Fatal(keys, err) + } + h := lanGuard(New().Handler()) + call := func() int { + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq)) + r.RemoteAddr = "192.168.1.9:5000" + r.Header.Set("Authorization", "Bearer "+s.LANKey) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code + } + if code := call(); code != http.StatusOK { + t.Fatal("migrated remote caller", code) + } + rec := lastUsage(t) + if rec.CallerKeyID != keys[0].ID || rec.CallerKeyName != keys[0].Name || rec.Input != 30 || rec.Output != 5 { + t.Fatal("remote usage attribution", rec) + } + if err := access.ConfigureLAN(true, true); err != nil { + t.Fatal(err) + } + if code := call(); code != http.StatusUnauthorized { + t.Fatal("rotated LAN credential still works", code) + } + s.LANKey, err = access.Update("copy-key", access.Change{Key: keys[0].ID}) + if err != nil { + t.Fatal(err) + } + if code := call(); code != http.StatusOK || lastUsage(t).CallerKeyID != keys[0].ID { + t.Fatal("rotation changed remote usage identity", code) + } + if _, err := access.Update("remove-key", access.Change{Key: keys[0].ID}); err != nil { + t.Fatal(err) + } + if code := call(); code != http.StatusUnauthorized { + t.Fatal("revoked old LAN credential still works", code) + } + if recs := usage.Load(time.Time{}); len(recs) != 2 { + t.Fatal("unauthorized request counted as usage", recs) + } +} + +func TestImageUsageIncludesCaller(t *testing.T) { + s, _ := easeled(t) + keys, secrets := newCaller(t, "Drawing") + r := httptest.NewRequest("POST", "/v1/images/generations", strings.NewReader(`{"model":"art/gpt-image-1","prompt":"a bird"}`)) + r.Header.Set("Authorization", "Bearer "+secrets[0]) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + rec := lastUsage(t) + if rec.CallerKeyID != keys[0].ID || rec.ProviderKeyID != provider.KeyID("key") || rec.Input != 7 { + t.Fatal(rec) + } +} + +func TestCallerIdentitySurvivesStreamingFailover(t *testing.T) { + fresh(t) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer first" { + w.WriteHeader(429) + io.WriteString(w, `{"error":{"message":"rate limited"}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "data: "+`{"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}`+"\n\n") + io.WriteString(w, "data: "+`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":20}}`+"\n\ndata: [DONE]\n\n") + })) + defer up.Close() + p := provider.Provider{ID: "plan", Name: "Plan", Key: "first", Chat: up.URL + "/v1", Models: []string{"m1"}, + Keys: []provider.KeyAccount{{Key: "backup", Name: "Backup"}}} + if err := provider.Save(p); err != nil { + t.Fatal(err) + } + keys, secrets := newCaller(t, "Stream") + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"plan/m1","messages":[{"role":"user","content":"hi"}],"stream":true}`)) + r.Header.Set("Authorization", "Bearer "+secrets[0]) + w := httptest.NewRecorder() + s := New() + s.Handler().ServeHTTP(w, r) + if w.Code != 200 || !strings.Contains(w.Body.String(), "[DONE]") { + t.Fatal(w.Code, w.Body.String()) + } + rec := lastUsage(t) + if rec.RouteID == 0 || rec.RouteID != lastRoute(s).ID || rec.CallerKeyID != keys[0].ID || rec.CallerKeyName != "Stream" || rec.ProviderKeyID != provider.KeyID("backup") || rec.ProviderKeyName != "Backup" || rec.Input != 100 || rec.Output != 20 { + t.Fatal(rec) + } +} + +func TestCallerUsageWithRequestArchive(t *testing.T) { + for _, header := range []string{"Authorization", "x-api-key", "query"} { + t.Run(header, func(t *testing.T) { + fresh(t) + serveOn(t, "fake", "provider-secret", []string{"m1"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer provider-secret" { + t.Error("caller credential reached archived request upstream") + } + archiveVendor{}.ServeHTTP(w, r) + })) + keys, secrets := newCaller(t, "Archived client") + shared := settings.Load() + shared.RequestArchive = true + if err := settings.Save(shared); err != nil { + t.Fatal(err) + } + bucket := &memBucket{objs: map[string][]byte{}} + archiveTo(t, bucket) + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"fake/m1","messages":[{"role":"user","content":"hi"}]}`)) + r.RemoteAddr = "192.168.1.9:5000" + switch header { + case "Authorization": + r.Header.Set(header, "Bearer "+secrets[0]) + case "query": + r.URL.RawQuery = "key=" + secrets[0] + default: + r.Header.Set(header, secrets[0]) + } + s := New() + w := httptest.NewRecorder() + lanGuard(s.Handler()).ServeHTTP(w, r) + archivePending.Wait() + if w.Code != 200 || w.Header().Get(ArchiveHeader) == "" || len(bucket.objs) != 1 { + t.Fatal("named caller request was not archived", w.Code, w.Body) + } + for _, data := range bucket.objs { + if strings.Contains(string(data), secrets[0]) || strings.Contains(string(data), "provider-secret") { + t.Fatal("credential leaked into request archive") + } + } + rec := lastUsage(t) + if rec.RouteID == 0 || rec.RouteID != lastRoute(s).ID || rec.CallerKeyID != keys[0].ID || rec.CallerKeyName != "Archived client" || rec.ProviderKeyID != provider.KeyID("provider-secret") || rec.Input != 3 { + t.Fatal("archive lost usage attribution", rec) + } + }) + } +} diff --git a/internal/gateway/caller_video_usage_test.go b/internal/gateway/caller_video_usage_test.go new file mode 100644 index 00000000..9b93a5fe --- /dev/null +++ b/internal/gateway/caller_video_usage_test.go @@ -0,0 +1,57 @@ +package gateway + +import ( + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/yetone/magpie/internal/usage" +) + +func TestCallerUsageVideos(t *testing.T) { + for _, tc := range []struct { + prompt string + status int + }{ + {"a fox", 200}, + {"TOOLONG", 400}, + {"NOID", 502}, + } { + for _, header := range []string{"Authorization", "x-api-key", "query"} { + t.Run(tc.prompt+"/"+header, func(t *testing.T) { + grokSignedIn(t) + newGrokMedia(t) + keys, secrets := newCaller(t, "Video client") + r := httptest.NewRequest("POST", "/v1/videos", strings.NewReader(`{"model":"grok/grok-imagine-video","prompt":"`+tc.prompt+`"}`)) + r.RemoteAddr = "192.168.1.9:5000" + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Magpie-Session", "video-session") + if header == "query" { + r.URL.RawQuery = "key=" + secrets[0] + } else if header == "Authorization" { + r.Header.Set(header, "Bearer "+secrets[0]) + } else { + r.Header.Set(header, secrets[0]) + } + w := httptest.NewRecorder() + lanGuard(New().Handler()).ServeHTTP(w, r) + if w.Code != tc.status { + t.Fatal(w.Code, w.Body) + } + rec := lastUsage(t) + if rec.CallerKeyID != keys[0].ID || rec.CallerKeyName != "Video client" || rec.Provider != "grok" || rec.Model != "grok-imagine-video" || rec.Status != tc.status || rec.Session != "video-session" { + t.Fatalf("video caller attribution: %+v", rec) + } + rows, _, _ := usage.Ledger(usage.All, usage.Filter{CallerKey: keys[0].ID}) + if len(rows) != 1 { + t.Fatal("video missing from caller-key ledger", rows) + } + log, err := os.ReadFile(usage.Path()) + if err != nil || strings.Contains(string(log), secrets[0]) { + t.Fatal("video usage leaked the caller credential", err) + } + }) + } + } +} diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index 431ebdee..cb166d53 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -403,7 +403,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri Reasoning: uu.Reasoning, Millis: call.Millis, TTFT: call.TTFT, FirstText: call.FirstText, Status: call.Status, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header), Kind: call.Kind, RequestID: requestID(res.Header), Endpoint: r.URL.Path} failedWith(&rec, call.Status, call.Error, errType) - usage.Append(rec) + appendUsage(r, rec) } // unreadableItem is the item OpenAI's refusal names: sealed content it diff --git a/internal/gateway/decide.go b/internal/gateway/decide.go index 54702ee1..cf149d32 100644 --- a/internal/gateway/decide.go +++ b/internal/gateway/decide.go @@ -465,7 +465,7 @@ func (s *Server) serveSystemOne(w http.ResponseWriter, r *http.Request) { if p.Account == nil && p.Key != "" { keyID, keyName = provider.KeyID(p.Key), p.KeyName } - usage.Append(usage.Record{RouteID: tr.ID, Time: start, Agent: agentOf(r), Provider: p.ID, Host: p.Where(), Model: model, Requested: asked, Served: use.Model, + appendUsage(r, usage.Record{RouteID: tr.ID, Time: start, Agent: agentOf(r), Provider: p.ID, Host: p.Where(), Model: model, Requested: asked, Served: use.Model, ProviderKeyID: keyID, ProviderKeyName: keyName, Input: use.Usage.Input, Output: use.Usage.Output, Millis: time.Since(start).Milliseconds(), Status: status}) end(status, errMsg, tokens) diff --git a/internal/gateway/draw.go b/internal/gateway/draw.go index 0659cd6b..aeab6e86 100644 --- a/internal/gateway/draw.go +++ b/internal/gateway/draw.go @@ -243,7 +243,7 @@ func (s *Server) images(edit bool) http.HandlerFunc { if p.Account == nil && p.Key != "" { providerKeyID, providerKeyName = provider.KeyID(p.Key), p.KeyName } - usage.Append(usage.Record{Time: start, Agent: call.Agent, Via: call.Via, Provider: p.ID, Host: p.Where(), Model: model, Requested: call.Model, ProviderKeyID: providerKeyID, ProviderKeyName: providerKeyName, + appendUsage(r, usage.Record{Time: start, Agent: call.Agent, Via: call.Via, Provider: p.ID, Host: p.Where(), Model: model, Requested: call.Model, ProviderKeyID: providerKeyID, ProviderKeyName: providerKeyName, Input: out.Input, Output: out.Output, Millis: call.Millis, Status: call.Status, Session: sessionOf(r.Header)}) if err != nil { call.Error = err.Error() diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 5a25c773..54adfe46 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -294,7 +294,7 @@ var WhileServing []func(context.Context) // long as a stream takes. A bind error means another magpie is already // serving, which is fine for the caller to ignore. func (s *Server) ListenAndServe(ctx context.Context) error { - loadLANKey() + migrateLANKeyBestEffort() ln, err := Listen(listenAddr()) if err != nil { return err @@ -392,7 +392,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { writeError(w, provider.Chat, http.StatusNotFound, "magpie serves /v1/chat/completions, /v1/responses, /v1/messages, /v1/systemone, /v1/images/generations, /v1/images/edits, /v1/videos and /v1beta/models/*") }) - return withCaller(mux) + return callerGuard(withCaller(mux)) } func (s *Server) info(w http.ResponseWriter, r *http.Request) { @@ -818,7 +818,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro rec := usage.Record{Time: start, Agent: call.Agent, Via: call.Via, Provider: call.Provider, Model: call.Model, Requested: call.Model, Millis: call.Millis, Status: call.Status, Rejected: true, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header), Kind: call.Kind, Endpoint: endpointOf(r, from, "")} failedWith(&rec, call.Status, call.Error, "") - usage.Append(rec) + appendUsage(r, rec) } // a model's id without a provider in it that names a routing group is // the group's, as "group/" is, rather than one provider's that @@ -1225,7 +1225,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro TTFT: try.TTFT, FirstText: try.FirstText, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header), Kind: call.Kind, RequestID: call.Usage.RequestID, Endpoint: endpointOf(r, from, call.To)} failedWith(&rec, call.Status, call.Error, call.Usage.ErrType) - usage.Append(rec) + appendUsage(r, rec) } continue } @@ -1383,7 +1383,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro TTFT: call.TTFT, FirstText: call.FirstText, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header), Kind: call.Kind, RequestID: call.Usage.RequestID, Endpoint: endpointOf(r, from, call.To)} failedWith(&rec, call.Status, call.Error, call.Usage.ErrType) - usage.Append(rec) + appendUsage(r, rec) } } diff --git a/internal/gateway/lan.go b/internal/gateway/lan.go index 4d9fecdb..aa27df6f 100644 --- a/internal/gateway/lan.go +++ b/internal/gateway/lan.go @@ -2,34 +2,26 @@ package gateway import ( "context" - "crypto/rand" - "crypto/subtle" - "encoding/hex" "net" "net/http" "net/url" "os" "path/filepath" "strings" - "sync/atomic" + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/provider" "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/usage" ) -// The gateway listens on loopback and takes any token, which is safe only -// because nothing but this computer reaches it. Shared on the local network -// (settings' LAN), it listens on every interface, and a request from -// another machine must carry the key magpie made for it, as its API key; -// this computer's agents go on as before. - -// lanKey is the key a request from another machine must carry, "" while -// the gateway isn't shared. -var lanKey atomic.Pointer[string] +// The gateway accepts named caller keys locally and, while shared, remotely. +// Loopback clients may still use any token, including a stale named key. // listenAddr is where the gateway listens: every interface while it is // shared, on its port, else its address. func listenAddr() string { - if s := settings.Load(); s.LAN && s.LANKey != "" { + if s := settings.Load(); s.LAN { return "0.0.0.0:" + Port() } return Addr() @@ -44,19 +36,10 @@ func Port() string { return p } -func loadLANKey() { - k := "" - if s := settings.Load(); s.LAN { - k = s.LANKey - } - lanKey.Store(&k) -} +func migrateLANKey() error { return access.MigrateLegacyLANKey() } -// NewLANKey makes a key for sharing the gateway. -func NewLANKey() string { - b := make([]byte, 20) - rand.Read(b) - return "sk-magpie-" + hex.EncodeToString(b) +func migrateLANKeyBestEffort() { + access.MigrateLegacyLANKeyBestEffort() } // publicURL is MAGPIE_PUBLIC_URL, the base URL other machines are told to @@ -130,10 +113,9 @@ func LANURLs() []string { } // Relisten moves the gateway to where settings now say it listens — onto -// the network or back to loopback — and takes up the key; requests in -// flight finish. +// the network or back to loopback. Requests in flight finish. func (s *Server) Relisten() error { - loadLANKey() + migrateLANKeyBestEffort() s.lnMu.Lock() defer s.lnMu.Unlock() if s.ln == nil { @@ -158,50 +140,75 @@ func (s *Server) Relisten() error { return nil } -// lanGuard lets a request from another machine through only with the key, -// and hands it on as though it came from here, with the gateway's token. -// A gateway put on the network with MAGPIE_ADDR and not shared from the -// Settings page is open, as it has always been. +// lanGuard requires a named caller key on the local network. An explicit +// MAGPIE_ADDR without LAN sharing retains its existing open-gateway behavior. func lanGuard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if local(r) { - next.ServeHTTP(w, r) - return - } - key := "" - if k := lanKey.Load(); k != nil { - key = *k - } - if key == "" { - if os.Getenv("MAGPIE_ADDR") != "" { - next.ServeHTTP(w, r) - return - } + remote := !local(r) + shared := settings.Load().LAN + if remote && !shared && os.Getenv("MAGPIE_ADDR") == "" { http.Error(w, "magpie isn't shared on the local network", http.StatusForbidden) return } - if subtle.ConstantTimeCompare([]byte(callerKey(r)), []byte(key)) != 1 { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":{"type":"authentication_error","message":"use the API key shown in magpie's Settings, under Share on local network"}}`)) - return - } - r = r.WithContext(context.WithValue(r.Context(), lanKeyed{}, true)) - r.Header.Set("Authorization", "Bearer "+Token) - if r.Header.Get("x-api-key") != "" { - r.Header.Set("x-api-key", Token) - } - if r.Header.Get("x-goog-api-key") != "" { - r.Header.Set("x-goog-api-key", Token) - } - if q := r.URL.Query(); q.Get("key") != "" { - q.Set("key", Token) - r.URL.RawQuery = q.Encode() + if (remote && shared) || (!remote && access.Managed(callerKey(r))) { + var ok bool + r, ok = identifyCaller(w, r) + if !ok { + return + } + if remote && shared { + r = r.WithContext(context.WithValue(r.Context(), lanKeyed{}, true)) + } } next.ServeHTTP(w, r) }) } +// callerGuard also covers embedded handlers used by the web app and tests. +func callerGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if access.Caller(r.Context()).KeyID == "" && access.Managed(callerKey(r)) && (local(r) || settings.Load().LAN) { + var ok bool + r, ok = identifyCaller(w, r) + if !ok { + return + } + } + next.ServeHTTP(w, r) + }) +} + +func identifyCaller(w http.ResponseWriter, r *http.Request) (*http.Request, bool) { + who, ok := access.Authenticate(callerKey(r)) + if !ok && !local(r) { + writeError(w, provider.Chat, http.StatusUnauthorized, "API key is disabled, removed or invalid") + return r, false + } + if ok { + r = r.WithContext(access.WithIdentity(r.Context(), who)) + } + r.Header = r.Header.Clone() + r.Header.Set("Authorization", "Bearer "+Token) + for _, h := range []string{"x-api-key", "x-goog-api-key"} { + if r.Header.Get(h) != "" { + r.Header.Set(h, Token) + } + } + if q := r.URL.Query(); q.Get("key") != "" { + q.Set("key", Token) + u := *r.URL + u.RawQuery = q.Encode() + r.URL = &u + } + return r, true +} + +func appendUsage(r *http.Request, rec usage.Record) { + who := access.Caller(r.Context()) + rec.CallerKeyID, rec.CallerKeyName = who.KeyID, who.KeyName + usage.Append(rec) +} + // lanKeyed marks a request from another machine that carried the key. type lanKeyed struct{} diff --git a/internal/gateway/lan_test.go b/internal/gateway/lan_test.go index e7a76870..dbdcf0a3 100644 --- a/internal/gateway/lan_test.go +++ b/internal/gateway/lan_test.go @@ -6,17 +6,22 @@ import ( "os" "path/filepath" "testing" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/settings" ) -// Shared on the network, a request from another machine needs the key, and -// goes on with the gateway's own token; this computer's need none. +// Sharing gates remote access; each authorized remote call carries the same +// named identity as its local counterpart, so usage is attributed by key. func TestLANGuard(t *testing.T) { - var got string + fresh(t) + var got, id string h := lanGuard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("Authorization") + "|" + r.Header.Get("x-api-key") + id = access.Caller(r.Context()).KeyID })) call := func(from string, hdr ...string) int { - got = "" + got, id = "", "" r := httptest.NewRequest("POST", "/v1/messages", nil) r.RemoteAddr = from for i := 0; i+1 < len(hdr); i += 2 { @@ -26,35 +31,52 @@ func TestLANGuard(t *testing.T) { h.ServeHTTP(w, r) return w.Code } - key := "sk-magpie-k" - none := "" - t.Cleanup(func() { lanKey.Store(&none) }) - - lanKey.Store(&none) + secret, err := access.Update("add-key", access.Change{Name: "Laptop"}) + if err != nil { + t.Fatal(err) + } + keys, _ := access.List() + keyID := keys[0].ID t.Setenv("MAGPIE_ADDR", "") - if c := call("192.168.1.9:5000"); c != http.StatusForbidden { - t.Fatal("unshared, another machine got", c) + if c := call("192.168.1.9:5000", "x-api-key", secret); c != http.StatusForbidden { + t.Fatal("unshared remote", c) } - if c := call("127.0.0.1:5000"); c != 200 { - t.Fatal("loopback got", c) + if c := call("127.0.0.1:5000", "Authorization", "Bearer anything"); c != 200 { + t.Fatal("loopback", c) } - t.Setenv("MAGPIE_ADDR", "0.0.0.0:3425") + s := settings.Load() + s.LAN = true + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if addr := listenAddr(); addr != "0.0.0.0:"+Port() { + t.Fatal(addr) + } + for _, hdr := range [][]string{nil, {"Authorization", "Bearer magpie"}, {"x-api-key", "wrong"}} { + if c := call("192.168.1.9:5000", hdr...); c != http.StatusUnauthorized { + t.Fatal("unauthorized remote", hdr, c) + } + } + if c := call("192.168.1.9:5000", "x-api-key", secret); c != 200 || id != keyID || got != "Bearer magpie|magpie" { + t.Fatal(c, id, got) + } + if c := call("127.0.0.1:5000", "Authorization", "Bearer "+secret); c != 200 || id != keyID { + t.Fatal("local named key", c, id) + } + access.Update("off-key", access.Change{Key: keyID}) + if c := call("192.168.1.9:5000", "x-api-key", secret); c != http.StatusUnauthorized { + t.Fatal("disabled key", c) + } + access.Update("on-key", access.Change{Key: keyID}) + access.Update("remove-key", access.Change{Key: keyID}) + if c := call("192.168.1.9:5000", "x-api-key", secret); c != http.StatusUnauthorized { + t.Fatal("removed key", c) + } + s.LAN = false + settings.Save(s) + t.Setenv("MAGPIE_ADDR", "0.0.0.0:3499") if c := call("192.168.1.9:5000"); c != 200 { - t.Fatal("MAGPIE_ADDR's open gateway got", c) - } - - lanKey.Store(&key) - if c := call("192.168.1.9:5000"); c != http.StatusUnauthorized { - t.Fatal("no key got", c) - } - if c := call("192.168.1.9:5000", "Authorization", "Bearer wrong"); c != http.StatusUnauthorized { - t.Fatal("a wrong key got", c) - } - if c := call("192.168.1.9:5000", "x-api-key", key); c != 200 || got != "Bearer magpie|magpie" { - t.Fatal(c, got) - } - if c := call("[::1]:5000", "Authorization", "Bearer anything"); c != 200 || got != "Bearer anything|" { - t.Fatal("loopback", c, got) + t.Fatal("explicit open gateway", c) } } @@ -70,8 +92,8 @@ func TestURLOfWildcard(t *testing.T) { } } -// An agent whose User-Agent is only the AI SDK's (Alma) is known by the -// token it was given; the others still by their User-Agent. +// An agent whose User-Agent is only the AI SDK's (Alma) is known by +// the token it was given; the others still by their User-Agent. func TestAgentOf(t *testing.T) { for _, c := range []struct{ auth, key, ua, want string }{ {"Bearer " + TokenFor("alma"), "", "ai-sdk/openai/2.0.52 ai-sdk/provider-utils/3.0.12 runtime/node.js/v22", "alma"}, diff --git a/internal/gateway/quotas_lan_test.go b/internal/gateway/quotas_lan_test.go index 22dcf7e1..c7f44485 100644 --- a/internal/gateway/quotas_lan_test.go +++ b/internal/gateway/quotas_lan_test.go @@ -5,6 +5,9 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/settings" ) // GET /v1/magpie/quotas answers another machine with the key of the @@ -12,7 +15,7 @@ import ( // a wrong key, none, or a gateway not shared still get nothing, and the // refusal says how. func TestQuotasOverLAN(t *testing.T) { - setHome(t, t.TempDir()) + fresh(t) t.Setenv("MAGPIE_ADDR", "") h := lanGuard(New().Handler()) call := func(from string, hdr ...string) (int, string) { @@ -25,11 +28,8 @@ func TestQuotasOverLAN(t *testing.T) { h.ServeHTTP(w, r) return w.Code, w.Body.String() } - key := "sk-magpie-k" - none := "" - t.Cleanup(func() { lanKey.Store(&none) }) - - lanKey.Store(&key) + keys, secrets := newCaller(t, "Quota client") + key := secrets[0] if c, b := call("100.64.0.7:5000", "Authorization", "Bearer "+key); c != 200 || !strings.Contains(b, `"object":"list"`) { t.Fatal("shared, with the key:", c, b) } @@ -46,7 +46,17 @@ func TestQuotasOverLAN(t *testing.T) { t.Fatal("loopback got", c) } - lanKey.Store(&none) + if _, err := access.Update("off-key", access.Change{Key: keys[0].ID}); err != nil { + t.Fatal(err) + } + if c, _ := call("192.168.1.9:5000", "Authorization", "Bearer "+key); c != http.StatusUnauthorized { + t.Fatal("disabled key got", c) + } + shared := settings.Load() + shared.LAN = false + if err := settings.Save(shared); err != nil { + t.Fatal(err) + } if c, _ := call("192.168.1.9:5000", "Authorization", "Bearer "+key); c != http.StatusForbidden { t.Fatal("not shared got", c) } diff --git a/internal/gateway/session_route_test.go b/internal/gateway/session_route_test.go index a26923c2..58525f8b 100644 --- a/internal/gateway/session_route_test.go +++ b/internal/gateway/session_route_test.go @@ -11,7 +11,9 @@ import ( "testing" "time" + "github.com/yetone/magpie/internal/access" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" ) // GET /v1/magpie/route tells an agent's UI where its session's turn went @@ -132,16 +134,34 @@ func TestSessionRoute(t *testing.T) { t.Fatal("no session got", code) } // another machine needs the shared gateway's key, as the quotas do - key, none := "sk-magpie-k", "" - t.Cleanup(func() { lanKey.Store(&none) }) - lanKey.Store(&key) + callerKeys, secrets := newCaller(t, "Session status") + key := secrets[0] if code, _ := get("192.168.1.9:5000", "/v1/magpie/route?session=pi-1"); code != http.StatusUnauthorized { t.Fatal("no key got", code) } if code, out := get("192.168.1.9:5000", "/v1/magpie/route?session=pi-1", "Authorization", "Bearer "+key); code != 200 || out["route"] == nil { t.Fatalf("with the key: %d %v", code, out) } - lanKey.Store(&none) + if code, out := get("192.168.1.9:5000", "/v1/magpie/route?session=pi-1", "x-api-key", key); code != 200 || out["route"] == nil { + t.Fatalf("with x-api-key: %d %v", code, out) + } + if code, out := get("192.168.1.9:5000", "/v1/magpie/route?session=pi-1&key="+key); code != 200 || out["route"] == nil { + t.Fatalf("with query key: %d %v", code, out) + } + if code, _ := get("192.168.1.9:5000", "/v1/magpie/route?session=pi-1", "Authorization", "Bearer wrong"); code != http.StatusUnauthorized { + t.Fatal("wrong key got", code) + } + if _, err := access.Update("off-key", access.Change{Key: callerKeys[0].ID}); err != nil { + t.Fatal(err) + } + if code, _ := get("192.168.1.9:5000", "/v1/magpie/route?session=pi-1", "Authorization", "Bearer "+key); code != http.StatusUnauthorized { + t.Fatal("disabled key got", code) + } + shared := settings.Load() + shared.LAN = false + if err := settings.Save(shared); err != nil { + t.Fatal(err) + } t.Setenv("MAGPIE_ADDR", "0.0.0.0:3425") if code, _ := get("192.168.1.9:5000", "/v1/magpie/route?session=pi-1"); code != http.StatusForbidden { t.Fatal("MAGPIE_ADDR, not shared, got", code) diff --git a/internal/gateway/video.go b/internal/gateway/video.go index 4948e926..0d808cbe 100644 --- a/internal/gateway/video.go +++ b/internal/gateway/video.go @@ -461,7 +461,7 @@ func (s *Server) videosCreate(w http.ResponseWriter, r *http.Request) { call.Status = code } } - usage.Append(usage.Record{Time: start, Agent: call.Agent, Via: call.Via, Provider: p.ID, Host: p.Where(), Model: model, Requested: call.Model, + appendUsage(r, usage.Record{Time: start, Agent: call.Agent, Via: call.Via, Provider: p.ID, Host: p.Where(), Model: model, Requested: call.Model, Millis: call.Millis, Status: call.Status, Session: sessionOf(r.Header)}) if err != nil { call.Error = err.Error() diff --git a/internal/gui/api.go b/internal/gui/api.go index fefe79a2..2b831146 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -20,6 +20,7 @@ import ( "strings" "time" + "github.com/yetone/magpie/internal/access" "github.com/yetone/magpie/internal/agent" "github.com/yetone/magpie/internal/autostart" "github.com/yetone/magpie/internal/catalog" @@ -269,6 +270,7 @@ func searchState(s *settingsJSON) { func settingsState() settingsJSON { s := settingsJSON{Settings: settings.Load(), Version: Version, Dir: tilde(settings.Dir()), Gateway: gateway.URL()} + s.LANKey = "" // the retained credential belongs on disk, not in UI state if found, err := discoverTerminals(); err == nil { for _, app := range found.Apps { s.TerminalApps = append(s.TerminalApps, terminalChoice{ID: app.ID, Name: app.Name}) @@ -546,12 +548,14 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { providerRoutes(mux, w) importRoutes(mux) usageRoutes(mux, w) + callerKeyRoutes(mux) sessionRoutes(mux, w) backupRoutes(mux, w) archiveRoutes(mux) libraryRoutes(mux, w) updateRoutes(mux, w) mux.HandleFunc("GET /api/settings", func(rw http.ResponseWriter, r *http.Request) { + access.MigrateLegacyLANKeyBestEffort() writeJSON(rw, settingsState()) }) mux.HandleFunc("POST /api/settings", func(rw http.ResponseWriter, r *http.Request) { @@ -576,6 +580,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { in.Visible, in.HiddenModels = cur.Visible, cur.HiddenModels settings.CarryPerModel(&in, &cur) in.LAN, in.LANKey = cur.LAN, cur.LANKey + in.LANKeyID = cur.LANKeyID in.RequestArchive = cur.RequestArchive // the Gateway page's, set on its own in.RedactRules = cur.RedactRules // the masking rules, set on their own // used or left is the Usage page's toggle as much as Settings', set on its own @@ -750,20 +755,15 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { } writeJSON(rw, settingsState()) }) - // sharing the gateway on the local network: on makes its key the first - // time, new asks for another (the old one stops working) + // Sharing controls exposure; the gateway's named caller keys authenticate + // remote clients just as they do local ones. mux.HandleFunc("POST /api/settings/lan", func(rw http.ResponseWriter, r *http.Request) { var in struct{ On, NewKey bool } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { fail(rw, err) return } - s := settings.Load() - s.LAN = in.On - if s.LANKey == "" || in.NewKey { - s.LANKey = gateway.NewLANKey() - } - if err := settings.Save(s); err != nil { + if err := access.ConfigureLAN(in.On, in.NewKey); err != nil { fail(rw, err) return } diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index ebca2a39..2758347d 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1334,8 +1334,12 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .connect .hint { font-size: 11px; color: var(--faint); margin-top: 4px; line-height: 1.4; } .connect .val { display: inline-flex; align-items: center; gap: 4px; height: 26px; padding: 0 4px 0 9px; border-radius: 7px; background: var(--pill); box-shadow: inset 0 0 0 1px var(--line-2); max-width: 100%; } .connect .val code { font-size: 12px; color: var(--fg); user-select: text; -webkit-user-select: text; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.connect .val > .val { min-width: 0; height: auto; padding: 0; background: none; box-shadow: none; } .connect .segs { margin-top: -1px; } .connect .stack { display: flex; flex-direction: column; gap: 8px; min-width: 0; } +@media (max-width: 520px) { + .connect { grid-template-columns: 48px minmax(0, 1fr); column-gap: 8px; padding-left: 10px; padding-right: 10px; } +} .copy { width: 22px; height: 22px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); flex: none; transition: background .12s, color .12s; } .copy:hover { background: var(--pill-hover); color: var(--fg); } .copy.done { color: var(--green); background: var(--green-soft); animation: copy-pop .36s var(--ease); } @@ -1494,6 +1498,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .acc .proto:hover, .acc .proto.open { color: var(--fg); background: var(--pill-hover); } .acc .n, .acc .rename { flex: 0 1 auto; } .acc .plan { flex: none; } +#gatewayKeys .acc { min-width: 0; } +#gatewayKeys .acc .plan { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } /* the Settings row's picker: the key row's badge, always shown */ .row.pref .proto.pick { flex: none; margin-left: auto; display: inline-flex; align-items: center; gap: 3px; height: 24px; padding: 0 6px 0 9px; font: inherit; font-size: 12px; color: var(--fg-2); border: 0; border-radius: 7px; background: var(--pill); white-space: nowrap; cursor: default; transition: background .12s, color .12s; } .row.pref .proto.pick svg { opacity: .7; transition: transform .15s var(--ease); } @@ -1656,14 +1662,14 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .stepplan .steps { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 8px; font-size: 12px; color: var(--muted); line-height: 1.5; } .stepplan .steps .pair { margin-top: 2px; } .stepplan .bookmarklet { display: inline-block; padding: 1px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--fg); background: var(--pill); text-decoration: none; cursor: grab; white-space: nowrap; } -.editor input[type="text"], .editor input[type="password"], .editor input[type="url"] { +.editor input[type="text"], .editor input[type="password"], .editor input[type="url"], .acc .kf input, .acc input.rename-in { height: 27px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); font-size: 12.5px; outline: none; width: 100%; min-width: 0; transition: border-color .12s, box-shadow .12s; } -.editor input:focus, .editor .headers textarea.json:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; } -.editor input::placeholder { color: var(--faint); } +.editor input:focus, .editor .headers textarea.json:focus, .acc input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; } +.editor input::placeholder, .acc input::placeholder { color: var(--faint); } .editor .pair { display: flex; gap: 8px; align-items: center; } .editor .pair input:first-child { flex: 1; } .editor .side { display: flex; gap: 4px; align-items: center; flex: none; min-width: 52px; justify-content: flex-end; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index e12e1d89..cec722f7 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2493,13 +2493,13 @@ function askForgetSaved(x) { return; } } - closeResetAsk(); + closeConfirmAsk(); }; const cancel = el("button", "text", t("Cancel")); - cancel.onclick = (e) => { e.stopPropagation(); closeResetAsk(); }; + cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); }; bar.append(el("span", "grow"), cancel, go); ed.append(bar); - resetAsk = ed; + confirmAsk = ed; openModal(ed); $("#modal").classList.add("lib"); cancel.focus(); @@ -2628,6 +2628,9 @@ function renderGatewayView() { page.classList.remove("loading"); page.removeAttribute("aria-busy"); renderGateway(); + $("#gatewayKeysBlock").hidden = !providers.gateway.lan; + if (!providers.gateway.lan) gatewayKeyDraft = null; + if (gatewayKeyDraft === null && !$("#gatewayKeys .rename-in")) renderGatewayKeys(); renderConnect(); renderGatewayModels(); renderArchive(); @@ -2662,19 +2665,19 @@ const FLAVORS = { openai: { name: "OpenAI", base: (u) => u + "/v1", baseEnv: "OPENAI_BASE_URL", keyEnv: "OPENAI_API_KEY", note: "Chat Completions, the API most tools speak. Anything with an OpenAI base-URL setting works.", - curl: (b, m) => ({ url: `${b}/chat/completions`, headers: ["Authorization: Bearer magpie"], + curl: (b, m, k = "magpie") => ({ url: `${b}/chat/completions`, headers: [`Authorization: Bearer ${k}`], body: `{"model": "${m}",\n "messages": [{"role": "user", "content": "hi"}]}` }), - python: (b, m) => `from openai import OpenAI + python: (b, m, k = "magpie") => `from openai import OpenAI -client = OpenAI(base_url="${b}", api_key="magpie") +client = OpenAI(base_url="${b}", api_key="${k}") r = client.chat.completions.create( model="${m}", messages=[{"role": "user", "content": "hi"}], ) print(r.choices[0].message.content)`, - node: (b, m) => `import OpenAI from "openai"; + node: (b, m, k = "magpie") => `import OpenAI from "openai"; -const client = new OpenAI({ baseURL: "${b}", apiKey: "magpie" }); +const client = new OpenAI({ baseURL: "${b}", apiKey: "${k}" }); const r = await client.chat.completions.create({ model: "${m}", messages: [{ role: "user", content: "hi" }], @@ -2684,28 +2687,28 @@ console.log(r.choices[0].message.content);`, responses: { name: "Responses", base: (u) => u + "/v1", baseEnv: "OPENAI_BASE_URL", keyEnv: "OPENAI_API_KEY", note: "OpenAI's newer API: reasoning, built-in tool items, encrypted reasoning. Codex speaks this.", - curl: (b, m) => ({ url: `${b}/responses`, headers: ["Authorization: Bearer magpie"], + curl: (b, m, k = "magpie") => ({ url: `${b}/responses`, headers: [`Authorization: Bearer ${k}`], body: `{"model": "${m}", "input": "hi"}` }), - python: (b, m) => `from openai import OpenAI + python: (b, m, k = "magpie") => `from openai import OpenAI -client = OpenAI(base_url="${b}", api_key="magpie") +client = OpenAI(base_url="${b}", api_key="${k}") r = client.responses.create(model="${m}", input="hi") print(r.output_text)`, - node: (b, m) => `import OpenAI from "openai"; + node: (b, m, k = "magpie") => `import OpenAI from "openai"; -const client = new OpenAI({ baseURL: "${b}", apiKey: "magpie" }); +const client = new OpenAI({ baseURL: "${b}", apiKey: "${k}" }); const r = await client.responses.create({ model: "${m}", input: "hi" }); console.log(r.output_text);`, }, anthropic: { name: "Anthropic", base: (u) => u, baseEnv: "ANTHROPIC_BASE_URL", keyEnv: "ANTHROPIC_API_KEY", note: "Messages API. Claude Code reads ANTHROPIC_AUTH_TOKEN instead of the key; the Agents tab sets that for you.", - curl: (b, m) => ({ url: `${b}/v1/messages`, headers: ["x-api-key: magpie", "anthropic-version: 2023-06-01"], + curl: (b, m, k = "magpie") => ({ url: `${b}/v1/messages`, headers: [`x-api-key: ${k}`, "anthropic-version: 2023-06-01"], body: `{"model": "${m}", "max_tokens": 1024,\n "messages": [{"role": "user", "content": "hi"}]}` }), - python: (b, m) => `import anthropic + python: (b, m, k = "magpie") => `import anthropic client = anthropic.Anthropic( - base_url="${b}", api_key="magpie", + base_url="${b}", api_key="${k}", ) m = client.messages.create( model="${m}", @@ -2713,9 +2716,9 @@ m = client.messages.create( messages=[{"role": "user", "content": "hi"}], ) print(m.content[0].text)`, - node: (b, m) => `import Anthropic from "@anthropic-ai/sdk"; + node: (b, m, k = "magpie") => `import Anthropic from "@anthropic-ai/sdk"; -const client = new Anthropic({ baseURL: "${b}", apiKey: "magpie" }); +const client = new Anthropic({ baseURL: "${b}", apiKey: "${k}" }); const m = await client.messages.create({ model: "${m}", max_tokens: 1024, @@ -2726,17 +2729,17 @@ console.log(m.content[0].text);`, gemini: { name: "Gemini", base: (u) => u, baseEnv: "GOOGLE_GEMINI_BASE_URL", keyEnv: "GEMINI_API_KEY", note: "Google's generateContent API, v1beta. Gemini CLI and the google-genai SDKs speak this.", - curl: (b, m) => ({ url: `${b}/v1beta/models/${m}:generateContent`, headers: ["x-goog-api-key: magpie"], + curl: (b, m, k = "magpie") => ({ url: `${b}/v1beta/models/${m}:generateContent`, headers: [`x-goog-api-key: ${k}`], body: `{"contents": [{"parts": [{"text": "hi"}]}]}` }), - python: (b, m) => `from google import genai + python: (b, m, k = "magpie") => `from google import genai -client = genai.Client(api_key="magpie", http_options={"base_url": "${b}"}) +client = genai.Client(api_key="${k}", http_options={"base_url": "${b}"}) r = client.models.generate_content(model="${m}", contents="hi") print(r.text)`, - node: (b, m) => `import { GoogleGenAI } from "@google/genai"; + node: (b, m, k = "magpie") => `import { GoogleGenAI } from "@google/genai"; const ai = new GoogleGenAI({ - apiKey: "magpie", + apiKey: "${k}", httpOptions: { baseUrl: "${b}" }, }); const r = await ai.models.generateContent({ model: "${m}", contents: "hi" }); @@ -2811,7 +2814,20 @@ function renderConnect() { if (!models.some((m) => m.id === exampleModel)) exampleModel = models[0]?.id || ""; const model = exampleModel || "provider/model"; const f = FLAVORS[flavor] || FLAVORS.openai; - const base = f.base(g.url); + const urls = [g.url, ...(g.lanURLs || [])]; + if (!urls.includes(connectURL)) connectURL = g.url; + const remote = connectURL !== g.url; + const keys = g.lan ? (gatewayKeys || []).filter((k) => !k.off) : []; + if (!keys.some((k) => k.id === connectKeyID)) { + connectKeyID = ""; + connectSecret = null; + } + if (remote && !connectKeyID && keys.length) { + queueMicrotask(() => selectConnectKey(keys[0].id)); + } + const key = keys.find((k) => k.id === connectKeyID); + const secret = key ? (connectSecret?.id === key.id ? connectSecret.secret : "") : remote ? "" : "magpie"; + const base = f.base(connectURL); const fold = $("#foldConnect"); fold.setAttribute("aria-expanded", String(!connectFolded)); fold.title = t(connectFolded ? "Show how to connect" : "Fold Connect away"); @@ -2821,17 +2837,31 @@ function renderConnect() { note.replaceChildren(); note.classList.toggle("brief", connectFolded); if (connectFolded) note.append(el("code", "", base), copyBtn(base, "Base URL")); - else note.textContent = t("Loopback only · the key can be anything"); + else note.textContent = t(remote ? "Local network · an enabled gateway key is required" : g.lan && gatewayKeys?.length ? "Use a gateway key to track usage" : "Loopback only · the key can be anything"); box.append(...field("API", segs(Object.entries(FLAVORS).map(([k, v]) => [k, v.name]), flavor, (id) => { flavor = id; localStorage.setItem("magpie.flavor", id); renderConnect(); }), t(f.note))); const b = el("div", "val"); - b.append(el("code", "", base), copyBtn(base, "Base URL")); + if (urls.length > 1) b.append(connectPick("connectAddress", "Address", base, + urls.map((u) => ({ v: u, name: f.base(u), note: t(u === g.url ? "This computer" : "Local network") })), connectURL, + (u) => { connectURL = u; renderConnect(); })); + else b.append(el("code", "", base)); + b.append(copyBtn(base, "Base URL")); box.append(...field("Base URL", b, t("What {env} takes.", { env: f.baseEnv }))); const k = el("div", "val"); - k.append(el("code", "", "magpie"), copyBtn("magpie", t("Key"))); - box.append(...field(t("API key"), k, t("{env}=magpie. The gateway trusts everything on loopback, so any value works.", { env: f.keyEnv }))); + const options = keys.map((k) => ({ v: k.id, name: k.name, literalName: true, note: k.masked })); + if (!remote) options.unshift({ v: "", name: "This computer", note: t("Any key · no key attribution") }); + const keyLabel = remote || key ? "Gateway key" : "API key"; + if (!g.lan) k.append(el("code", "", "magpie")); + else if (options.length) k.append(connectPick("connectKey", keyLabel, key ? key.name : remote ? t("Choose a gateway key") : t("This computer"), + options, connectKeyID, selectConnectKey)); + else k.append(el("code", "", t("Create a gateway key above to connect"))); + if (key) k.append(copyCallerKeyBtn(key)); + else if (!remote) k.append(copyBtn("magpie", t("Key"))); + box.append(...field(t(keyLabel), k, t(g.lan && (remote || gatewayKeys?.length) + ? "Choose a gateway key to use as {env}; usage is tracked by key." + : "{env}=magpie. The gateway trusts everything on loopback, so any value works.", { env: f.keyEnv }))); const m = el("div", "val"); m.append(el("code", "", model), copyBtn(model, t("Model id"))); @@ -2839,16 +2869,18 @@ function renderConnect() { const ex = el("div", "stack"); ex.append(segs(LANGS, lang, (id) => { lang = id; localStorage.setItem("magpie.lang", id); renderConnect(); })); - const code = lang === "shell" ? envSnippet([[f.baseEnv, base], [f.keyEnv, "magpie"]]) - : lang === "curl" ? curlSnippet(f.curl(base, model)) - : f[lang](base, model); + const code = !secret ? t(key ? "Loading gateway key…" : "Create a gateway key above to connect") + : lang === "shell" ? envSnippet([[f.baseEnv, base], [f.keyEnv, secret]]) + : lang === "curl" ? curlSnippet(f.curl(base, model, secret)) + : f[lang](base, model, secret); const pre = el("pre", "snip"); const c = el("code"); c.append(highlight(code, lang)); pre.append(c); // the button sits outside the scrolling box, so a long line doesn't carry it off const wrap = el("div", "snip-wrap"); - wrap.append(pre, copyBtn(code, t("Snippet"))); + wrap.append(pre); + if (secret) wrap.append(copyBtn(code, t("Snippet"))); ex.append(wrap); box.append(...field(t("Example"), ex, lang === "shell" ? t("Put these in the shell (or the tool's settings) and the tool talks to magpie instead of the vendor.") : "")); } @@ -6515,7 +6547,7 @@ function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key sp b.type = "button"; b.setAttribute("role", multi && o.v && o.v !== "\x00" ? "menuitemcheckbox" : "menuitemradio"); const words = el("span", "pm-words"); - words.append(el("span", "pm-name", t(o.name)), el("span", "pm-note", t(o.note))); + words.append(el("span", "pm-name", o.literalName ? o.name : t(o.name)), el("span", "pm-note", t(o.note))); b.append(el("span", "pm-tick"), words); tick(b, o); b.onclick = (e) => { @@ -6914,7 +6946,7 @@ function renderUsageLoading() { stats.append(tile); } $("#chart").hidden = true; - for (const id of ["usageAgents", "usageModels"]) $("#" + id).hidden = true; + for (const id of ["usageAgents", "usageModels", "usageKeys"]) $("#" + id).hidden = true; for (const h of $$("#view-usage .row-head")) h.hidden = true; $("#usageNote").textContent = ""; } @@ -7509,7 +7541,7 @@ function autoResetButton(q, cls) { // askReset: spending a Codex account's reset can't be taken // back, so it asks first; then it says what came of it and reads the // usage again. -let resetAsk = null; +let confirmAsk = null; function askReset(q) { const ed = el("div", "editor reset-ask"); const head = el("div", "ehead"); @@ -7530,7 +7562,7 @@ function askReset(q) { go.classList.add("busy"); try { const out = await api("usage/codex-reset", { user: q.user || "" }); - closeResetAsk(); + closeConfirmAsk(); status(who + ": " + resetOutcome(out), out.code === "reset" ? "ok" : "err"); loadQuotas(); } catch (err) { @@ -7540,26 +7572,26 @@ function askReset(q) { } }; const cancel = el("button", "text", t("Cancel")); - cancel.onclick = (e) => { e.stopPropagation(); closeResetAsk(); }; + cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); }; bar.append(el("span", "grow"), cancel, go); ed.append(bar); - resetAsk = ed; + confirmAsk = ed; openModal(ed); $("#modal").classList.add("lib"); go.focus(); } -function closeResetAsk() { - if (!resetAsk) return; - resetAsk = null; - closeModal().then(() => { if (!resetAsk) $("#modal").classList.remove("lib"); }); +function closeConfirmAsk() { + if (!confirmAsk) return; + confirmAsk = null; + closeModal().then(() => { if (!confirmAsk) $("#modal").classList.remove("lib"); }); } // the dialog is the providers page's: while this asks, its backdrop and -// Escape close only this (in the panel, Escape would hide the window) +// Escape closes only the confirmation (in the panel it would hide the window) $("#modal").addEventListener("click", (e) => { - if (resetAsk && e.target === e.currentTarget) { e.stopImmediatePropagation(); closeResetAsk(); } + if (confirmAsk && e.target === e.currentTarget) { e.stopImmediatePropagation(); closeConfirmAsk(); } }, true); document.addEventListener("keydown", (e) => { - if (resetAsk && e.key === "Escape") { e.preventDefault(); e.stopImmediatePropagation(); closeResetAsk(); } + if (confirmAsk && e.key === "Escape") { e.preventDefault(); e.stopImmediatePropagation(); closeConfirmAsk(); } }, true); function resetOutcome(out) { switch (out.code) { @@ -7744,6 +7776,7 @@ function renderUsage() { $("#chart").hidden = empty; for (const id of ["usageAgents", "usageModels"]) $("#" + id).hidden = empty; for (const h of $$("#view-usage .row-head")) h.hidden = empty; + $("#usageKeysHead").hidden = $("#usageKeys").hidden = empty || !u.callerKeys?.length; if (empty) { stats.classList.add("empty"); const none = { today: "No calls today.", "7d": "No calls in the last 7 days.", "30d": "No calls in the last 30 days.", all: "No calls yet." }[period]; @@ -7829,6 +7862,7 @@ function renderUsage() { }; list("usageAgents", u.agents); list("usageModels", u.models); + list("usageKeys", u.callerKeys || []); $("#usageNote").textContent = t("Counted from the providers' own usage reports on every call through the gateway · {path}", { path: u.path }); } @@ -7841,7 +7875,7 @@ function renderUsage() { // server pages it (/api/usage/requests) and saves it whole as CSV. let ledger = null; // the page shown: { rows, offset, total, agents, …totals } -let ledOffset = 0, ledAgent = "", ledProvider = "", ledFailed = false, ledQuery = "", ledModel = "", ledRoute = 0; +let ledOffset = 0, ledAgent = "", ledProvider = "", ledCallerKey = "", ledFailed = false, ledQuery = "", ledModel = "", ledRoute = 0; const LED_PAGE = 100; // Remember the chart metric; start each app load split by model. let ledMetric = "tokens", ledSplit = "model"; @@ -7852,10 +7886,10 @@ try { let ledRouteInfo = null, ledBeforeRoute = null; window.openUsageRoute = (route) => { - if (!ledBeforeRoute) ledBeforeRoute = { period, ledOffset, ledAgent, ledProvider, ledFailed, ledQuery, ledModel }; + if (!ledBeforeRoute) ledBeforeRoute = { period, ledOffset, ledAgent, ledProvider, ledCallerKey, ledFailed, ledQuery, ledModel }; ledRoute = route.id; ledRouteInfo = route; - ledOffset = 0; ledAgent = ""; ledProvider = ""; ledFailed = false; ledQuery = ""; ledModel = ""; + ledOffset = 0; ledAgent = ""; ledProvider = ""; ledCallerKey = ""; ledFailed = false; ledQuery = ""; ledModel = ""; $("#ledQ").value = ""; period = "all"; usageTab = "requests"; @@ -7867,6 +7901,7 @@ function ledParams(extra) { const q = new URLSearchParams({ period }); if (ledAgent) q.set("agent", ledAgent); if (ledProvider) q.set("provider", ledProvider); + if (ledCallerKey) q.set("callerKey", ledCallerKey); if (ledRoute) q.set("route", ledRoute); if (ledFailed) q.set("failed", "1"); if (ledModel) q.set("model", ledModel); @@ -8393,6 +8428,14 @@ const LED_COLS = [ function renderLedger() { const l = ledger; + const callers = l.callerKeys || []; + if (ledCallerKey && !callers.some((k) => k.id === ledCallerKey)) { + ledCallerKey = ""; + ledOffset = 0; + // Reload the rows too: this response still belongs to the missing key. + loadLedger().catch((e) => status(e.message, "err")); + return; + } const view = $("#view-usage"); view.classList.remove("loading"); view.removeAttribute("aria-busy"); @@ -8413,6 +8456,9 @@ function renderLedger() { const providers = l.providers || []; if (ledProvider && !providers.some((p) => p.id === ledProvider)) ledProvider = ""; sessPick($("#ledProvider"), "All providers", ledProvider, providers.map((p) => ({ v: p.id, name: t(p.name), note: "" })), "Provider", (v) => { ledProvider = v; ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); }); + sessPick($("#ledKey"), "All gateway keys", ledCallerKey, callers.map((k) => ({ + v: k.id, name: k.name, note: "", + })), "Gateway keys", (v) => { ledCallerKey = v; ledOffset = 0; loadLedger().catch((e) => status(e.message, "err")); }); const seg = $("#ledStatus"); seg.replaceChildren(); for (const [on, name] of [[false, "All"], [true, "Failed"]]) { @@ -8442,7 +8488,7 @@ function renderLedger() { $("#ledRouteClear").setAttribute("aria-label", t("Clear filter")); $("#ledRouteClear").onclick = () => { ledRoute = 0; ledRouteInfo = null; - if (ledBeforeRoute) ({ period, ledOffset, ledAgent, ledProvider, ledFailed, ledQuery, ledModel } = ledBeforeRoute); + if (ledBeforeRoute) ({ period, ledOffset, ledAgent, ledProvider, ledCallerKey, ledFailed, ledQuery, ledModel } = ledBeforeRoute); ledBeforeRoute = null; $("#ledQ").value = ledQuery; loadLedger().catch((e) => status(e.message, "err")); @@ -8453,7 +8499,7 @@ function renderLedger() { const pager = $("#ledPager"); if (!l.total) { wrap.classList.add("none"); - const filtered = ledRoute || ledAgent || ledProvider || ledFailed || ledQuery.trim(); + const filtered = ledRoute || ledAgent || ledProvider || ledCallerKey || ledModel || ledFailed || ledQuery.trim(); const none = { today: "No calls today.", "7d": "No calls in the last 7 days.", "30d": "No calls in the last 30 days.", all: "No calls yet." }[period]; wrap.replaceChildren(el("div", "led-none", filtered ? t("No requests match these filters.") : t(none))); pager.hidden = true; @@ -8489,7 +8535,7 @@ function renderLedger() { const who = el("span", "who"); // an agent on another computer, whose magpie passed the request on const name = r.agentName || r.agent; - who.append(icon(r.icon || "generic"), el("span", "", r.via ? t("{agent} · via {host}", { agent: name, host: r.via }) : name)); + who.append(icon(r.icon || "generic"), el("span", "", [r.via ? t("{agent} · via {host}", { agent: name, host: r.via }) : name, r.callerKeyLabel || r.callerKeyName].filter(Boolean).join(" · "))); td(who, "", [r.kind, r.session && t("session {id}", { id: r.session })].filter(Boolean).join(" · ")); td(r.req || "—", "model" + (r.req ? "" : " faint"), r.req || t("Not kept for requests before this version")); const local = r.source === "log"; @@ -10914,7 +10960,7 @@ function renderRedactRules(s, row) { } // renderLAN: the gateway shared on the local network, for agents on other -// machines — the addresses they use it at and the key they must send. +// machines. Gateway keys and connection examples live together in Gateway. let lanSelectedURL = "", lanProtocol = "openai"; function renderLAN(s) { const box = $("#lanList"); @@ -10933,7 +10979,7 @@ function renderLAN(s) { }; const set = (body) => writingPrefs(api("settings/lan", body)).then((ns) => { prefs = ns; renderSettings(); }) .catch((e) => { status(t(e.message), "err"); renderSettings(); }); - row(t("Share on local network"), t("Agents on other computers on this network can use magpie’s models, with the API key below"), "", + row(t("Share on local network"), t("Agents on other computers on this network can use magpie’s models with a gateway key from Gateway"), "", segs([["off", t("Off")], ["on", t("On")]], s.lan ? "on" : "off", (v) => set({ on: v === "on" }))); if (!s.lan) return; let urls = s.lanURLs || [], sub = ""; @@ -10981,10 +11027,6 @@ function renderLAN(s) { update(); } if (sub) row(t("In a container"), sub, "").classList.add("lan-container"); - const again = el("button", "text", t("New key")); - again.onclick = () => set({ on: true, newKey: true }); - row(t("API key"), t("Other computers send it as their API key; a new one stops the old from working"), - s.lanKey.slice(0, 14) + "…", copyBtn(s.lanKey, t("API key")), again); } // renderUpdate fills in the version row: whether a newer magpie is out. @@ -11355,6 +11397,7 @@ function show(v) { closePicker(); closeAgentModels(); if (v !== "providers" && editing !== null) cancelEdit(); + if (v === "gateway") loadGatewayKeys(); if (v === "providers" || v === "gateway" || v === "routing") loadProviders().then(back, (e) => status(e.message, "err")); if (v === "usage") loadUsage(true).then(back, (e) => status(e.message, "err")); if (v === "settings") loadSettings().then(back, (e) => status(e.message, "err")); diff --git a/internal/gui/assets/caller-keys.js b/internal/gui/assets/caller-keys.js new file mode 100644 index 00000000..787b9c47 --- /dev/null +++ b/internal/gui/assets/caller-keys.js @@ -0,0 +1,187 @@ +// Caller keys use the same account list and edit controls as provider keys. +let gatewayKeys = null, gatewayKeyDraft = null, gatewayKeysBusy = false; +let gatewayKeysRead = 0; +let connectURL = "", connectKeyID = "", connectSecret = null; + +function connectPick(id, label, text, options, value, choose) { + const b = el("button", "val"); + b.id = id; + b.type = "button"; + b.setAttribute("aria-label", t(label)); + b.append(el("code", "", text), svg(CHEV, 11, 1.6)); + b.onclick = () => { + if (b.classList.contains("open")) return closeProtoMenu(); + openProtoMenu(b, options, value, choose, label); + }; + return b; +} + +async function selectConnectKey(id) { + connectKeyID = id; + connectSecret = null; + renderConnect(); + if (!id || !providers?.gateway.lan) return; + const key = gatewayKeys?.find((k) => k.id === id && !k.off); + if (!key) return; + try { + const out = await api("caller-keys/copy-key", { key: id }); + if (connectKeyID !== id || gatewayKeys?.find((k) => k.id === id) !== key) return; + connectSecret = { id, secret: out.secret }; + if (view === "gateway") renderConnect(); + } catch (e) { status(t(e.message), "err"); } +} +async function loadGatewayKeys() { + const read = ++gatewayKeysRead; + try { + const out = await api("caller-keys"); + if (read !== gatewayKeysRead) return; + // A fresh list also refreshes a selected credential, which may have rotated. + connectSecret = null; + gatewayKeys = out.keys || []; + renderGatewayKeys(); + if (view === "gateway" && providers) selectConnectKey(connectKeyID); + } catch (e) { + if (read !== gatewayKeysRead) return; + $("#gatewayKeys").replaceChildren(el("div", "none", t(e.message))); + } +} + +async function gatewayKeyAction(action, body) { + if (gatewayKeysBusy) return null; + gatewayKeysBusy = true; + gatewayKeysRead++; + try { + const out = await api("caller-keys/" + action, body); + gatewayKeysRead++; + connectSecret = null; + gatewayKeys = out.keys || []; + gatewayKeyDraft = null; + renderGatewayKeys(); + if (view === "gateway" && providers) selectConnectKey(connectKeyID); + return out; + } catch (e) { + status(t(e.message), "err"); + return null; + } finally { + gatewayKeysBusy = false; + } +} + +function gatewayRename(k) { + const b = el("button", "n rename", k.name); + b.title = t("Rename"); + b.onclick = () => { + const i = input(k.name, t("Name")); + i.className = "rename-in"; + i.setAttribute("aria-label", t("Name")); + let finished = false; + const done = async (save) => { + if (finished) return; + finished = true; + // Keep the other controls in place when blur precedes their click. + i.replaceWith(b); + if (save && i.value.trim() !== k.name) await gatewayKeyAction("rename-key", { key: k.id, name: i.value }); + }; + i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") done(true); else if (e.key === "Escape") done(false); }; + i.onblur = () => done(true); + b.replaceWith(i); + i.focus({ preventScroll: true }); + }; + return b; +} + +function gatewayKeyForm() { + const box = el("div", "acc adding"); + const name = input(gatewayKeyDraft, t("Key name, e.g. Laptop")); + name.setAttribute("aria-label", t("Gateway key name")); + name.oninput = () => { gatewayKeyDraft = name.value; }; + const add = el("button", "text primary", t("Create")); + const go = async () => { + add.disabled = true; + const out = await gatewayKeyAction("add-key", { name: name.value }); + if (!out) { add.disabled = false; return; } + status(t("Gateway key created. Use Copy on its row to connect a client."), "ok"); + }; + add.onclick = go; + name.onkeydown = (e) => { + e.stopPropagation(); + if (e.key === "Enter" && !add.disabled) go(); + else if (e.key === "Escape") { gatewayKeyDraft = null; renderGatewayKeys(); } + }; + const cancel = el("button", "text", t("Cancel")); + cancel.onclick = () => { gatewayKeyDraft = null; renderGatewayKeys(); }; + const fields = el("div", "kf"); + fields.append(name); + const bar = el("div", "kb"); + bar.append(el("span", "grow"), cancel, add); + box.append(fields, bar); + queueMicrotask(() => name.focus({ preventScroll: true })); + return box; +} + +function copyCallerKeyBtn(k) { + const cp = copyBtn("", t("Gateway key")); + cp.setAttribute("aria-label", t("Copy gateway key")); + cp.onclick = async () => { + try { + const out = await api("caller-keys/copy-key", { key: k.id }); + await copy(out.secret, t("Gateway key"), cp); + } catch (e) { status(t(e.message), "err"); } + }; + return cp; +} + +function renderGatewayKeys() { + const box = $("#gatewayKeys"); + $("#gatewayKeysBlock").hidden = !providers?.gateway.lan; + box.replaceChildren(); + $("#addGatewayKey").onclick = () => { gatewayKeyDraft = ""; renderGatewayKeys(); }; + if (gatewayKeys === null) return; + const list = el("div", "accts"); + for (const k of gatewayKeys) { + const row = el("div", "acc" + (k.off ? " off" : " in-use")); + row.dataset.key = k.id; + const tick = el("button", "dot tick"); + tick.title = t(k.off ? "Enable key" : "Disable key"); + tick.setAttribute("aria-label", tick.title); + if (!k.off) tick.append(svg(CHECK, 10, 2.2)); + tick.onclick = () => gatewayKeyAction(k.off ? "on-key" : "off-key", { key: k.id }); + const rm = el("button", "text quiet", t("Remove")); + rm.onclick = () => askGatewayKey(k, false); + const rotate = el("button", "text quiet", t("Rotate key")); + rotate.onclick = () => askGatewayKey(k, true); + row.append(tick, gatewayRename(k), el("span", "plan mono", k.masked), el("span", "grow"), copyCallerKeyBtn(k), rotate, rm); + list.append(row); + } + if (gatewayKeyDraft !== null) list.append(gatewayKeyForm()); + else if (!gatewayKeys.length) list.append(el("div", "none", t("No gateway keys yet"))); + box.append(list); +} + +function askGatewayKey(k, rotate) { + const ed = el("div", "editor"); + const head = el("div", "ehead"); + head.append(el("b", "", t(rotate ? "Rotate gateway key?" : "Remove gateway key?"))); + ed.append(head, el("p", "lib-confirm", t(rotate + ? "Clients using {name} will need the new key. Its name and usage history stay the same." + : "Clients using {name} will lose access from other computers. Its usage history is kept.", { name: k.name }))); + const bar = el("div", "bar"); + const cancel = el("button", "text", t("Cancel")); + cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); }; + const go = el("button", "text primary", t(rotate ? "Rotate key" : "Remove")); + go.onclick = async (e) => { + e.stopPropagation(); + if (go.disabled) return; + go.disabled = true; + const out = await gatewayKeyAction(rotate ? "rotate-key" : "remove-key", { key: k.id }); + if (!out) { go.disabled = false; return; } + closeConfirmAsk(); + if (rotate) status(t("Gateway key rotated. Update clients on other computers."), "ok"); + }; + bar.append(el("span", "grow"), cancel, go); + ed.append(bar); + confirmAsk = ed; + openModal(ed); + $("#modal").classList.add("lib"); + cancel.focus({ preventScroll: true }); +} diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 19431d65..49ac90a2 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -4,6 +4,35 @@ // works. {name} slots are filled by t(str, vars). const I18N = { zh: { + "Gateway keys": "网关密钥", + "Gateway key": "网关密钥", + "All gateway keys": "全部网关密钥", + "Use a gateway key to track usage": "使用网关密钥统计用量", + "Choose a gateway key to use as {env}; usage is tracked by key.": "选择填入 {env} 的网关密钥,用量按密钥统计。", + "This computer": "本机", + "Any key · no key attribution": "任意密钥 · 不归属到具体密钥", + "Choose a gateway key": "选择网关密钥", + "Create a gateway key above to connect": "请先在上方创建网关密钥", + "Loading gateway key…": "正在读取网关密钥…", + "Local network · an enabled gateway key is required": "局域网接入需要已启用的网关密钥", + "Gateway key rotated. Update clients on other computers.": "网关密钥已轮换,请更新其他电脑上的客户端。", + "Add gateway key": "添加网关密钥", + "Create a gateway key for each client. Usage is counted separately for each key.": "为每个客户端创建一个网关密钥,分别统计用量。", + "Gateway key name": "网关密钥名称", + "Key name, e.g. Laptop": "密钥名称,例如笔记本", + "Create": "创建", + "Gateway key created. Use Copy on its row to connect a client.": "网关密钥已创建。点击该行的复制按钮即可配置客户端。", + "Copy gateway key": "复制网关密钥", + "Rotate key": "轮换密钥", + "No gateway keys yet": "尚无网关密钥", + "Rotate gateway key?": "轮换网关密钥?", + "Remove gateway key?": "移除网关密钥?", + "Clients using {name} will need the new key. Its name and usage history stay the same.": "使用 {name} 的客户端需要更新密钥,名称和用量历史保持不变。", + "Clients using {name} will lose access from other computers. Its usage history is kept.": "使用 {name} 的客户端将无法从其他电脑接入,用量历史会保留。", + "Enable key": "启用密钥", + "Disable key": "停用密钥", + "Use a name between 1 and 120 characters": "请输入 1 到 120 个字符的名称", + "Key not found": "密钥不存在", // header, nav, footer "Agents": "Agent", "Providers": "供应商", @@ -138,13 +167,12 @@ const I18N = { "Share on local network": "局域网共享", "{label} address": "{label} 地址", "Copied {label} address": "已复制 {label} 地址", - "Agents on other computers on this network can use magpie’s models, with the API key below": "同一网络里其他电脑上的 Agent 可以用下面的 API Key 使用 magpie 的模型", + "Agents on other computers on this network can use magpie’s models with a gateway key from Gateway": "同一网络里其他电脑上的 Agent 可以使用 magpie 的模型,网关密钥在网关中管理", "This computer has no local network address right now": "这台电脑现在没有局域网地址", "In a container": "在容器中运行", "Where this page was opened, on the gateway’s port; MAGPIE_PUBLIC_URL sets another": "即打开本页所用的地址,端口为网关的端口;可用 MAGPIE_PUBLIC_URL 改成别的", "The container’s own addresses, which other devices can’t reach: set MAGPIE_PUBLIC_URL to the host’s": "这是容器自己的地址,其他设备连不上:请把 MAGPIE_PUBLIC_URL 设为宿主机的地址", "New key": "换新 Key", - "Other computers send it as their API key; a new one stops the old from working": "其他电脑把它填作 API Key;换新后旧的立即失效", "API keys, private keys, tokens and passwords go to vendors as placeholders, and come back as they were": "API Key、私钥、Token 和密码以占位符发给模型厂商,回复里再还原", "Mask personal data": "脱敏个人信息", "Count me as a user": "计入用户数", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 5ec394e3..3c388d91 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -89,6 +89,11 @@
+
@@ -133,12 +138,15 @@
Models
+ +