gateway keys: a key can be created with a value of the user's own, one their clients already send, so moving from another gateway (CLIProxyAPI's api-keys) doesn't mean re-keying every client (love1sbug on X)
Gateway → Gateway keys → Add gateway key has a second field, "Your own key
(optional)"; `magpie gateway-key add <name> --key <value>` (or `--key -`
from stdin) does the same. Left empty, magpie makes a sk-magpie-key- as
before. A value is 8–256 printable ASCII characters with no spaces, can't
start with sk-magpie-, and can't be another key's. The list shows only its
last four characters ("…0042"); rotating it gives a magpie-made key.
From another computer any enabled key was already checked, so an own value
works there as is. From this computer the gateway only identified a request
whose key looked like sk-magpie-…, so an own value would have been let in
unidentified: not counted as the key, nor held to its limit, models or
accounts. managedKey now asks access.Named, which also knows the store's
own values (cached by the file's size and time).
Tested: go test -race . ./internal/access ./internal/gateway ./internal/gui;
-count=20 on the new and LAN guard tests; TestOwnKeyValueIsTheKey fails with
managedKey back on access.Managed. gateway-own-key.test.cjs passes 16/16
(chromium and webkit, en/zh/ja/de, 1000 and 440px), and the gateway-key
suites, i18n-keys, gui-ja and gui-de pass. A built binary in a sandbox HOME
with sharing on: own key from the LAN address 200, a wrong or no key 401,
from 127.0.0.1 200, the old value after rotate 401. GOOS=linux and windows
builds pass.
This commit is contained in:
1 parent
290c60a31e
commit
6b6113aa7a
11 files changed
+397
-17
No files matched your search
@@ -249,6 +249,11 @@ 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.
|
||||
To move from another gateway without touching its clients, enter the key they
|
||||
already send in **Your own key (optional)** (8–256 characters, no spaces, not
|
||||
starting with `sk-magpie-`, unlike any other key); left empty, magpie makes
|
||||
one. The list shows only its last four characters, and it is counted,
|
||||
limited and restricted as any other key, from this computer too.
|
||||
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**
|
||||
@@ -261,6 +266,7 @@ 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 add Family --key - # keeps a key clients already send, read from stdin
|
||||
magpie gateway-key list # ids, names, enabled state and masked keys
|
||||
magpie gateway-key rotate <id> # prints the replacement; identity stays the same
|
||||
magpie gateway-key remove <id> # revokes remote access
|
||||
|
||||
+20
-2
@@ -36,8 +36,26 @@ func gatewayKeysTo(out io.Writer, args []string) error {
|
||||
access.MigrateLegacyLANKeyBestEffort()
|
||||
return gatewayKeyAccounts(out, args[1:])
|
||||
}
|
||||
// add <name> --key <value>: the key keeps a value its clients already
|
||||
// send (love1sbug on X); "--key -" reads it from stdin, out of the
|
||||
// shell's history
|
||||
var own string
|
||||
if action == "add" && len(args) == 4 && args[2] == "--key" {
|
||||
own = args[3]
|
||||
if own == "-" {
|
||||
b, err := io.ReadAll(io.LimitReader(os.Stdin, 4096))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
own = strings.TrimSpace(string(b))
|
||||
if own == "" {
|
||||
return fmt.Errorf("no key came on stdin")
|
||||
}
|
||||
}
|
||||
args = args[:2]
|
||||
}
|
||||
if (action == "list" && len(args) != 1) || (action != "list" && len(args) != 2) {
|
||||
return fmt.Errorf("usage: magpie gateway-key list | add <name> | rotate <id> | remove <id> | limit <id> [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]] | models <id> [all | <provider>/<model>|<provider>/* ...] | accounts <id> [all | <provider>/<account>|<provider>/<key id> ...]")
|
||||
return fmt.Errorf("usage: magpie gateway-key list | add <name> [--key <value> | --key -] | rotate <id> | remove <id> | limit <id> [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]] | models <id> [all | <provider>/<model>|<provider>/* ...] | accounts <id> [all | <provider>/<account>|<provider>/<key id> ...]")
|
||||
}
|
||||
switch action {
|
||||
case "list", "add", "rotate", "remove":
|
||||
@@ -83,7 +101,7 @@ func gatewayKeysTo(out io.Writer, args []string) error {
|
||||
}
|
||||
in := access.Change{Key: args[1]}
|
||||
if action == "add" {
|
||||
in = access.Change{Name: args[1]}
|
||||
in = access.Change{Name: args[1], Secret: own}
|
||||
}
|
||||
secret, err := access.Update(action+"-key", in)
|
||||
if err != nil {
|
||||
|
||||
@@ -63,6 +63,44 @@ func TestGatewayKeyCLI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// add --key keeps a value clients already send (love1sbug on X); "-"
|
||||
// takes it from stdin.
|
||||
func TestGatewayKeyCLIOwnValue(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("USERPROFILE", os.Getenv("HOME"))
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
var out bytes.Buffer
|
||||
if err := gatewayKeysTo(&out, []string{"gateway-key", "add", "Family", "--key", "cpa-family-key-0042"}); err != nil || strings.TrimSpace(out.String()) != "cpa-family-key-0042" {
|
||||
t.Fatal(out.String(), err)
|
||||
}
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.WriteString("cpa-friend-key-0007\n")
|
||||
w.Close()
|
||||
stdin := os.Stdin
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = stdin }()
|
||||
if err := gatewayKeysTo(&bytes.Buffer{}, []string{"gateway-key", "add", "Friend", "--key", "-"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"cpa-family-key-0042", "cpa-friend-key-0007"} {
|
||||
if _, ok := access.Authenticate(k); !ok {
|
||||
t.Fatal("not a key:", k)
|
||||
}
|
||||
}
|
||||
out.Reset()
|
||||
if err := gatewayKeysTo(&out, []string{"gateway-key", "list"}); err != nil || strings.Contains(out.String(), "cpa-family") || !strings.Contains(out.String(), "…0042") {
|
||||
t.Fatal("list must mask an own value", out.String(), err)
|
||||
}
|
||||
for _, args := range [][]string{{"add", "X", "--key"}, {"add", "X", "--key", "short"}, {"add", "X", "--key", "cpa-family-key-0042"}, {"rotate", "x", "--key", "cpa-other-key-0001"}} {
|
||||
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"}
|
||||
|
||||
@@ -30,6 +30,82 @@ func Managed(secret string) bool {
|
||||
return strings.HasPrefix(secret, legacyLANPrefix)
|
||||
}
|
||||
|
||||
// Named: secret has a named gateway key's form (sk-magpie-…), or is the
|
||||
// key's own value a user gave it (Change.Secret), so a request from this
|
||||
// computer with one is counted and limited as that key, as a sk-magpie-
|
||||
// key is.
|
||||
func Named(secret string) bool {
|
||||
if Managed(secret) {
|
||||
return true
|
||||
}
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
own := ownSecrets()
|
||||
_, ok := own[secret]
|
||||
return ok
|
||||
}
|
||||
|
||||
// own caches the key store's own-value secrets by the file's size and
|
||||
// time, so a request from this computer doesn't read it each time.
|
||||
var own struct {
|
||||
sync.Mutex
|
||||
size int64
|
||||
mod int64
|
||||
secrets map[string]struct{}
|
||||
}
|
||||
|
||||
func ownSecrets() map[string]struct{} {
|
||||
fi, err := os.Stat(Path())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
own.Lock()
|
||||
defer own.Unlock()
|
||||
if own.secrets != nil && own.size == fi.Size() && own.mod == fi.ModTime().UnixNano() {
|
||||
return own.secrets
|
||||
}
|
||||
mu.Lock()
|
||||
keys, err := load()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return nil // unknown: tried again on the next request
|
||||
}
|
||||
m := map[string]struct{}{}
|
||||
for _, k := range keys {
|
||||
if k.Secret != "" && !Managed(k.Secret) {
|
||||
m[k.Secret] = struct{}{}
|
||||
}
|
||||
}
|
||||
own.size, own.mod, own.secrets = fi.Size(), fi.ModTime().UnixNano(), m
|
||||
return m
|
||||
}
|
||||
|
||||
// ownSecret checks a key value a user brings (one their clients already
|
||||
// send, from another gateway they move from, such as CLIProxyAPI's
|
||||
// api-keys, which are any string): what an HTTP header carries, and no
|
||||
// other key's.
|
||||
func ownSecret(secret string, keys []Key) (string, error) {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if n := len(secret); n < 8 || n > 256 {
|
||||
return "", errors.New("Use a key between 8 and 256 characters")
|
||||
}
|
||||
for i := 0; i < len(secret); i++ {
|
||||
if c := secret[i]; c <= ' ' || c > '~' {
|
||||
return "", errors.New("A key can have only letters, digits and ASCII symbols, no spaces")
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(secret, legacyLANPrefix) {
|
||||
return "", errors.New("A key of your own can't start with sk-magpie-: leave the key empty and magpie makes one")
|
||||
}
|
||||
for _, k := range keys {
|
||||
if subtle.ConstantTimeCompare([]byte(secret), []byte(k.Secret)) == 1 {
|
||||
return "", errors.New("Another gateway key already has this key")
|
||||
}
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
type Key struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -97,6 +173,12 @@ func List() ([]Key, error) {
|
||||
}
|
||||
k.Masked = prefix + "…" + k.Secret[len(k.Secret)-6:]
|
||||
}
|
||||
if k.Secret != "" && !Managed(k.Secret) { // a key of the user's own: only its end
|
||||
k.Masked = "…"
|
||||
if len(k.Secret) >= 12 {
|
||||
k.Masked += k.Secret[len(k.Secret)-4:]
|
||||
}
|
||||
}
|
||||
k.Secret = ""
|
||||
}
|
||||
return keys, nil
|
||||
@@ -162,6 +244,10 @@ type Change struct {
|
||||
// Accounts is what "accounts-key" sets (#905); none lets the key use
|
||||
// every account and key.
|
||||
Accounts []string `json:"accounts,omitempty"`
|
||||
// Secret is the value "add-key" gives the new key, one its clients
|
||||
// already send (love1sbug on X: keys handed out from another gateway);
|
||||
// "" for one magpie makes.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
// Update writes the named key store atomically.
|
||||
@@ -185,11 +271,17 @@ func Update(action string, in Change) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := random(24)
|
||||
if err != nil {
|
||||
return "", err
|
||||
if in.Secret != "" {
|
||||
if secret, err = ownSecret(in.Secret, keys); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
token, err := random(24)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret = Prefix + token
|
||||
}
|
||||
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 })
|
||||
|
||||
@@ -302,3 +302,51 @@ func TestLANRotationKeepsOlderMagpieWorking(t *testing.T) {
|
||||
t.Fatal("old key resurrected")
|
||||
}
|
||||
}
|
||||
|
||||
// A key can be given a value of the user's own, one their clients already
|
||||
// send (love1sbug on X), checked as a header can carry it and unlike any
|
||||
// other key's.
|
||||
func TestOwnKeyValue(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
const own = "cpa-family-key-0042"
|
||||
if Named(own) {
|
||||
t.Fatal("named before it is a key")
|
||||
}
|
||||
got, err := Update("add-key", Change{Name: "Family", Secret: " " + own + "\n"})
|
||||
if err != nil || got != own {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
keys, err := List()
|
||||
if err != nil || len(keys) != 1 || keys[0].Masked != "…0042" || keys[0].Secret != "" {
|
||||
t.Fatal(keys, err)
|
||||
}
|
||||
if who, ok := Authenticate(own); !ok || who.KeyName != "Family" {
|
||||
t.Fatal(who, ok)
|
||||
}
|
||||
if !Named(own) || Named(own+"x") || Named("") || !Named(Prefix+"abc") {
|
||||
t.Fatal("Named")
|
||||
}
|
||||
for _, bad := range []string{"short", own, "has a space in it", "tab\there-and-more", "非ascii-key-value", "sk-magpie-key-mine-1234", strings.Repeat("k", 257)} {
|
||||
if _, err := Update("add-key", Change{Name: "Other", Secret: bad}); err == nil {
|
||||
t.Fatalf("accepted %q", bad)
|
||||
}
|
||||
}
|
||||
if keys, _ := List(); len(keys) != 1 {
|
||||
t.Fatal("a refused key was kept", keys)
|
||||
}
|
||||
// rotating it gives it one magpie makes; the old value no longer works
|
||||
rotated, err := Update("rotate-key", Change{Key: keys[0].ID})
|
||||
if err != nil || !strings.HasPrefix(rotated, Prefix) {
|
||||
t.Fatal(rotated, err)
|
||||
}
|
||||
if _, ok := Authenticate(own); ok || Named(own) {
|
||||
t.Fatal("the old value still works")
|
||||
}
|
||||
short, err := Update("add-key", Change{Name: "Short", Secret: "abcd1234"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if keys, _ := List(); keys[1].Masked != "…" {
|
||||
t.Fatal("a short key shows its end", keys[1].Masked, short)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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"
|
||||
)
|
||||
|
||||
// A key given its own value — one clients already send, from another
|
||||
// gateway (love1sbug on X) — is that key from another computer and from
|
||||
// this one, as a sk-magpie- key is: counted as it, and never sent on.
|
||||
func TestOwnKeyValueIsTheKey(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:", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}`)
|
||||
}))
|
||||
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 = true
|
||||
if err := settings.Save(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const own = "cpa-family-key-0042"
|
||||
if _, err := access.Update("add-key", access.Change{Name: "Family", Secret: own}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := lanGuard(New().Handler()) // as the real server has it
|
||||
call := func(from, key string) int {
|
||||
r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq))
|
||||
r.RemoteAddr = from
|
||||
r.Header.Set("Authorization", "Bearer "+key)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
if code := call("192.0.2.7:4000", own); code != 200 {
|
||||
t.Fatal("from another computer", code)
|
||||
}
|
||||
if code := call("127.0.0.1:4000", own); code != 200 {
|
||||
t.Fatal("from this computer", code)
|
||||
}
|
||||
if code := call("192.0.2.7:4000", own+"x"); code != http.StatusUnauthorized {
|
||||
t.Fatal("a wrong key", code)
|
||||
}
|
||||
recs := usage.Load(time.Time{})
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("records: %+v", recs)
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if rec.CallerKeyName != "Family" {
|
||||
t.Fatalf("not counted as the key: %+v", rec)
|
||||
}
|
||||
}
|
||||
keys, err := access.List()
|
||||
if err != nil || len(keys) != 1 {
|
||||
t.Fatal(keys, err)
|
||||
}
|
||||
if _, err := access.Update("off-key", access.Change{Key: keys[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := call("192.0.2.7:4000", own); code != http.StatusUnauthorized {
|
||||
t.Fatal("a disabled key from another computer", code)
|
||||
}
|
||||
}
|
||||
@@ -342,10 +342,10 @@ func callerKeys(r *http.Request) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// managedKey: the request carries a named gateway key's form (sk-magpie-…)
|
||||
// in any of the places a key is read.
|
||||
// managedKey: the request carries a named gateway key's form (sk-magpie-…),
|
||||
// or a key's own value a user gave it, in any of the places a key is read.
|
||||
func managedKey(r *http.Request) bool {
|
||||
return slices.ContainsFunc(callerKeys(r), access.Managed)
|
||||
return slices.ContainsFunc(callerKeys(r), access.Named)
|
||||
}
|
||||
|
||||
// refusedKey says why a caller's key was turned away: none came, or the
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Caller keys use the same account list and edit controls as provider keys.
|
||||
let gatewayKeys = null, gatewayKeyDraft = null, gatewayKeysBusy = false;
|
||||
// gatewayOwnDraft is the value typed for a new key of the user's own (one
|
||||
// its clients already send, love1sbug on X); "" lets magpie make one
|
||||
let gatewayOwnDraft = "";
|
||||
// gatewayAccountNames maps a key's stored account entry ("<provider>/<id>",
|
||||
// #905) to how it is shown; what no provider has now is shown as it is kept
|
||||
let gatewayAccountNames = {};
|
||||
@@ -103,23 +106,31 @@ function gatewayKeyForm() {
|
||||
const name = input(gatewayKeyDraft, t("Key name, e.g. Laptop"));
|
||||
name.setAttribute("aria-label", t("Gateway key name"));
|
||||
name.oninput = () => { gatewayKeyDraft = name.value; };
|
||||
const own = input(gatewayOwnDraft, t("Your own key (optional)"), "password");
|
||||
own.setAttribute("aria-label", t("Gateway key value"));
|
||||
own.title = t("Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.");
|
||||
own.autocomplete = "off";
|
||||
own.oninput = () => { gatewayOwnDraft = own.value; };
|
||||
const add = el("button", "text primary", t("Create"));
|
||||
const go = async () => {
|
||||
add.disabled = true;
|
||||
const out = await gatewayKeyAction("add-key", { name: name.value });
|
||||
const mine = own.value.trim() !== "";
|
||||
const out = await gatewayKeyAction("add-key", mine ? { name: name.value, secret: own.value } : { name: name.value });
|
||||
if (!out) { add.disabled = false; return; }
|
||||
status(t("Gateway key created. Use Copy on its row to connect a client."), "ok");
|
||||
gatewayOwnDraft = "";
|
||||
status(t(mine ? "Gateway key created. Clients that send this key reach magpie now." : "Gateway key created. Use Copy on its row to connect a client."), "ok");
|
||||
};
|
||||
add.onclick = go;
|
||||
name.onkeydown = (e) => {
|
||||
const cancelled = () => { gatewayKeyDraft = null; gatewayOwnDraft = ""; renderGatewayKeys(); };
|
||||
for (const i of [name, own]) i.onkeydown = (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && !add.disabled) go();
|
||||
else if (e.key === "Escape") { gatewayKeyDraft = null; renderGatewayKeys(); }
|
||||
else if (e.key === "Escape") cancelled();
|
||||
};
|
||||
const cancel = el("button", "text", t("Cancel"));
|
||||
cancel.onclick = () => { gatewayKeyDraft = null; renderGatewayKeys(); };
|
||||
cancel.onclick = cancelled;
|
||||
const fields = el("div", "kf");
|
||||
fields.append(name);
|
||||
fields.append(name, own);
|
||||
const bar = el("div", "kb");
|
||||
bar.append(el("span", "grow"), cancel, add);
|
||||
box.append(fields, bar);
|
||||
|
||||
@@ -133,6 +133,14 @@ const I18N = {
|
||||
"Gateway key name": "网关密钥名称",
|
||||
"Key name, e.g. Laptop": "密钥名称,例如笔记本",
|
||||
"Create": "创建",
|
||||
"Your own key (optional)": "自定义密钥(可选)",
|
||||
"Gateway key value": "网关密钥的值",
|
||||
"Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.": "留空则由 magpie 生成密钥。也可以填写客户端已经在用的密钥(例如从其他网关迁移过来的),客户端无需改动。",
|
||||
"Gateway key created. Clients that send this key reach magpie now.": "网关密钥已创建,发送这个密钥的客户端现在就能连接 magpie。",
|
||||
"Use a key between 8 and 256 characters": "密钥长度需在 8 到 256 个字符之间",
|
||||
"A key can have only letters, digits and ASCII symbols, no spaces": "密钥只能包含字母、数字和 ASCII 符号,不能有空格",
|
||||
"A key of your own can't start with sk-magpie-: leave the key empty and magpie makes one": "自定义密钥不能以 sk-magpie- 开头:留空即可由 magpie 生成",
|
||||
"Another gateway key already has this key": "已有其他网关密钥使用了这个密钥",
|
||||
"Gateway key created. Use Copy on its row to connect a client.": "网关密钥已创建,用该行的复制按钮配置客户端。",
|
||||
"Copy gateway key": "复制网关密钥",
|
||||
"Rotate key": "轮换密钥",
|
||||
@@ -3717,6 +3725,14 @@ const I18N = {
|
||||
"Gateway key name": "ゲートウェイキー名",
|
||||
"Key name, e.g. Laptop": "キー名(例:ノートPC)",
|
||||
"Create": "作成",
|
||||
"Your own key (optional)": "独自のキー(任意)",
|
||||
"Gateway key value": "ゲートウェイキーの値",
|
||||
"Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.": "空欄なら magpie がキーを作成します。他のゲートウェイから移行する場合など、クライアントが既に送っているキーを入力すると、クライアントはそのまま使えます。",
|
||||
"Gateway key created. Clients that send this key reach magpie now.": "ゲートウェイキーを作成しました。このキーを送るクライアントはそのまま magpie に接続できます。",
|
||||
"Use a key between 8 and 256 characters": "キーは 8〜256 文字にしてください",
|
||||
"A key can have only letters, digits and ASCII symbols, no spaces": "キーに使えるのは英数字と ASCII 記号だけで、空白は使えません",
|
||||
"A key of your own can't start with sk-magpie-: leave the key empty and magpie makes one": "独自のキーは sk-magpie- で始められません。空欄にすると magpie が作成します",
|
||||
"Another gateway key already has this key": "このキーは別のゲートウェイキーで使われています",
|
||||
"Gateway key created. Use Copy on its row to connect a client.": "ゲートウェイキーを作成しました。行のコピーを使ってクライアントを接続してください。",
|
||||
"Copy gateway key": "ゲートウェイキーをコピー",
|
||||
"Rotate key": "キーを再発行",
|
||||
@@ -7296,6 +7312,14 @@ const I18N = {
|
||||
"Gateway key name": "Name des Zugangsschlüssels",
|
||||
"Key name, e.g. Laptop": "Schlüsselname, z. B. Laptop",
|
||||
"Create": "Erstellen",
|
||||
"Your own key (optional)": "Eigener Schlüssel (optional)",
|
||||
"Gateway key value": "Wert des Zugangsschlüssels",
|
||||
"Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.": "Leer lassen, dann erzeugt magpie einen Schlüssel. Oder einen eingeben, den Clients schon senden (etwa von einem anderen Gateway), damit sie weiterlaufen.",
|
||||
"Gateway key created. Clients that send this key reach magpie now.": "Zugangsschlüssel erstellt. Clients, die diesen Schlüssel senden, erreichen magpie jetzt.",
|
||||
"Use a key between 8 and 256 characters": "Der Schlüssel muss 8 bis 256 Zeichen lang sein",
|
||||
"A key can have only letters, digits and ASCII symbols, no spaces": "Ein Schlüssel darf nur Buchstaben, Ziffern und ASCII-Zeichen enthalten, keine Leerzeichen",
|
||||
"A key of your own can't start with sk-magpie-: leave the key empty and magpie makes one": "Ein eigener Schlüssel darf nicht mit sk-magpie- beginnen: leer lassen, dann erzeugt magpie einen",
|
||||
"Another gateway key already has this key": "Ein anderer Zugangsschlüssel hat diesen Schlüssel schon",
|
||||
"Gateway key created. Use Copy on its row to connect a client.": "Zugangsschlüssel erstellt. Mit „Kopieren“ in seiner Zeile einen Client verbinden.",
|
||||
"Copy gateway key": "Zugangsschlüssel kopieren",
|
||||
"Rotate key": "Schlüssel erneuern",
|
||||
|
||||
+4
-2
@@ -105,10 +105,12 @@ function fixture(lang, theme, events, options = {}) {
|
||||
let secret = "";
|
||||
if (action === "add-key") {
|
||||
if (!body.name.trim()) return route.fulfill({ status: 400, json: { error: "Use a name between 1 and 120 characters" } });
|
||||
// a value of the user's own, checked as access.ownSecret does
|
||||
if (body.secret !== undefined && body.secret.trim().length < 8) return route.fulfill({ status: 400, json: { error: "Use a key between 8 and 256 characters" } });
|
||||
const id = "new-key-" + (++serial);
|
||||
secret = "fixture-created-" + serial;
|
||||
secret = body.secret?.trim() || "fixture-created-" + serial;
|
||||
secrets.set(id, secret);
|
||||
keys.push({ id, name: body.name, masked: "sk-magpie-key-…created" });
|
||||
keys.push({ id, name: body.name, masked: body.secret ? "…" + secret.slice(-4) : "sk-magpie-key-…created" });
|
||||
}
|
||||
if (action === "rename-key") k.name = body.name;
|
||||
if (action === "rotate-key") {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// A gateway key can be created with a value of the user's own, one their
|
||||
// clients already send from another gateway (love1sbug on X).
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
const { fixture } = require("./fixtures/caller-keys.cjs");
|
||||
|
||||
const words = {
|
||||
en: { create: "Create", name: "Gateway key name", own: "Gateway key value", placeholder: "Your own key (optional)", made: "Clients that send this key reach magpie now", short: "Use a key between 8 and 256 characters" },
|
||||
zh: { create: "创建", name: "网关密钥名称", own: "网关密钥的值", placeholder: "自定义密钥(可选)", made: "发送这个密钥的客户端现在就能连接 magpie", short: "密钥长度需在 8 到 256 个字符之间" },
|
||||
ja: { create: "作成", name: "ゲートウェイキー名", own: "ゲートウェイキーの値", placeholder: "独自のキー(任意)", made: "このキーを送るクライアントはそのまま magpie に接続できます", short: "キーは 8〜256 文字にしてください" },
|
||||
de: { create: "Erstellen", name: null, own: "Wert des Zugangsschlüssels", placeholder: "Eigener Schlüssel (optional)", made: "Clients, die diesen Schlüssel senden, erreichen magpie jetzt", short: "Der Schlüssel muss 8 bis 256 Zeichen lang sein" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of Object.keys(words)) {
|
||||
for (const width of [1000, 440]) {
|
||||
test(`${engine} ${lang} ${width}px: create a gateway key with a value of one's own`, async (t) => {
|
||||
const w = words[lang];
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await (await browser.newContext({ viewport: { width, height: 760 }, reducedMotion: "reduce" })).newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
const events = [], errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", fixture(lang, "light", events, { lan: true }));
|
||||
await page.goto("http://magpie.test/?view=gateway");
|
||||
await page.locator("#gatewayKeys .acc[data-key]").last().waitFor();
|
||||
await page.locator("#addGatewayKey").click();
|
||||
const own = page.getByRole("textbox", { name: w.own, exact: true });
|
||||
const name = page.locator("#gatewayKeys .adding .kf input").first();
|
||||
assert.equal(await own.getAttribute("placeholder"), w.placeholder);
|
||||
assert.equal(await own.getAttribute("type"), "password");
|
||||
// both fields are wide enough to type in, side by side or stacked
|
||||
for (const box of [await name.boundingBox(), await own.boundingBox()]) assert(box.width >= 140, `field ${box.width}px wide`);
|
||||
await name.fill("Family");
|
||||
// a short value is refused and the form stays, with what was typed
|
||||
await own.fill("short");
|
||||
await own.press("Enter");
|
||||
await page.waitForFunction((m) => document.body.textContent.includes(m), w.short);
|
||||
assert.equal(await own.inputValue(), "short");
|
||||
await own.fill("cpa-family-key-0042");
|
||||
await page.getByRole("button", { name: w.create, exact: true }).click();
|
||||
const row = page.locator("#gatewayKeys .acc[data-key]", { hasText: "Family" });
|
||||
await row.waitFor();
|
||||
const sent = events.filter((e) => e.action === "add-key").at(-1).body;
|
||||
assert.deepEqual(sent, { name: "Family", secret: "cpa-family-key-0042" });
|
||||
assert.match(await row.textContent(), /…0042/);
|
||||
assert.doesNotMatch(await page.locator("#gatewayKeys").textContent(), /cpa-family/);
|
||||
await page.waitForFunction((m) => document.body.textContent.includes(m), w.made);
|
||||
// the next key starts empty: magpie makes it
|
||||
await page.locator("#addGatewayKey").click();
|
||||
assert.equal(await page.getByRole("textbox", { name: w.own, exact: true }).inputValue(), "");
|
||||
await page.locator("#gatewayKeys .adding .kf input").first().fill("Tablet");
|
||||
await page.getByRole("button", { name: w.create, exact: true }).click();
|
||||
await page.locator("#gatewayKeys .acc[data-key]", { hasText: "Tablet" }).waitFor();
|
||||
assert.deepEqual(events.filter((e) => e.action === "add-key").at(-1).body, { name: "Tablet" });
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user