Files
llmgw/internal/cli/keys.go
T
Ilker ManapandClaude Opus 5 a76ebdb79a
CI / test (push) Successful in 3m11s
Run the store on PostgreSQL, chosen by what the address looks like
A path is still SQLite and a postgres:// URL is PostgreSQL, so every existing
configuration keeps working untouched and moving is one line changed rather
than a new setting to find. An address of the form @/path/to/file reads the
real one out of that file, which is how a DSN carrying a password stays out of
a configuration that gets copied, diffed and pasted into issues.

The driver is pgx, which is pure Go. That is not a preference: the whole binary
is built with CGO_ENABLED=0 so it runs on a node whose libc is older than the
builder's, and a cgo driver would take that away from every deployment,
including the ones still on SQLite.

Tested against a real PostgreSQL, which found two things nothing else could.
The schema_migrations table is created in Go rather than in a migration file,
so it escaped the port and still said INTEGER — 64 bits in SQLite, 32 in
PostgreSQL, and a millisecond timestamp does not fit, so the very first
migration failed to record that it had run. And the memory migration uses an
FTS5 virtual table with three triggers keeping it in step, which has no
counterpart: PostgreSQL derives a tsvector from the row, so there is nothing
to keep in step and no trigger to forget.

That leaves exactly one query that is not the same sentence in both dialects —
the note search, MATCH against @@ — and it is written out twice rather than
hidden behind a shared spelling that would mean something subtly different in
each. Everything else is one set of queries.

The text search uses the 'simple' configuration rather than English. These are
an agent's own notes, in whatever language it chose, and stemming English
rules over Turkish would quietly stop finding things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 16:18:10 +02:00

328 lines
9.8 KiB
Go

package cli
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func newTenancyCmd() *cobra.Command {
var dbPath string
cmd := &cobra.Command{
Use: "keys",
Short: "Manage tenants and API keys",
Long: "Works directly on the store file rather than through the admin API, so keys\n" +
"can be issued before the server is running and without exposing an endpoint\n" +
"that mints credentials.",
}
cmd.PersistentFlags().StringVar(&dbPath, "store", "/var/lib/llmgw/llmgw.db", "path to the store")
open := func(ctx context.Context) (*store.SQLite, error) { return store.Open(ctx, dbPath) }
cmd.AddCommand(
newKeyInitCmd(open),
newKeyCreateCmd(open),
newKeyListCmd(open),
newKeyRevokeCmd(open),
newLimitsCmd(open),
newThreadsCmd(open),
newMemoryCmd(open),
)
return cmd
}
type opener func(context.Context) (*store.SQLite, error)
func newKeyInitCmd(open opener) *cobra.Command {
var org, team, project string
cmd := &cobra.Command{
Use: "init",
Short: "Create the tenancy tree a key hangs off",
Long: "Idempotent: running it again renames rather than duplicating, so it can be\n" +
"kept in whatever provisions the machine.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := open(cmd.Context())
if err != nil {
return err
}
defer s.Close()
ctx := cmd.Context()
if err := s.PutOrg(ctx, store.Org{ID: slug(org), Name: org}); err != nil {
return err
}
if err := s.PutTeam(ctx, store.Team{ID: slug(team), OrgID: slug(org), Name: team}); err != nil {
return err
}
if err := s.PutProject(ctx, store.Project{
ID: slug(project), TeamID: slug(team), Name: project,
}); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s / %s / %s\n", slug(org), slug(team), slug(project))
return nil
},
}
cmd.Flags().StringVar(&org, "org", "default", "organization name")
cmd.Flags().StringVar(&team, "team", "default", "team name")
cmd.Flags().StringVar(&project, "project", "default", "project name")
return cmd
}
func newKeyCreateCmd(open opener) *cobra.Command {
var project, band, profile, baseURL string
var expires, claimTTL time.Duration
var claim bool
cmd := &cobra.Command{
Use: "create <name>",
Short: "Issue an API key",
Long: "The key is printed once and never again: only its hash is stored, so there\n" +
"is nothing to recover it from. That is the point — a leaked database hands\n" +
"over no working credential.\n\n" +
"With --claim it prints a one-time link instead. Send the link rather than\n" +
"the key: a key pasted into a chat window stays in that chat window forever,\n" +
"while a link works once and then expires. The key is sealed with the link's\n" +
"own token, which is not stored, so a pending claim is not a usable\n" +
"credential sitting in the database either.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := open(cmd.Context())
if err != nil {
return err
}
defer s.Close()
secret, k, err := store.NewKey(randomID("key"), args[0], project)
if err != nil {
return err
}
k.CreatedAt = time.Now()
k.Band, k.RoutingProfile = band, profile
if expires > 0 {
k.ExpiresAt = k.CreatedAt.Add(expires)
}
if err := s.CreateKey(cmd.Context(), k); err != nil {
return fmt.Errorf("creating the key (does project %q exist? run `llmgw keys init`): %w",
project, err)
}
// The one output that matters goes to stdout and everything else to
// stderr, so `keys create ... > out.txt` captures it and nothing
// else. Cobra's Print writes to stderr, which would have made the
// useful half the half that cannot be piped.
out := cmd.OutOrStdout()
if claim {
if baseURL == "" {
return errors.New("--claim needs --base-url, the address users reach this gateway at")
}
token, err := store.NewClaimToken()
if err != nil {
return err
}
if err := s.CreateClaim(cmd.Context(), k.ID, token, secret, claimTTL); err != nil {
return err
}
link := strings.TrimRight(baseURL, "/") + "/claim/" + token
fmt.Fprintln(out, link)
cmd.PrintErrf("\nid %s\nproject %s\nexpires %s\n",
k.ID, k.ProjectID, time.Now().Add(orDefault(claimTTL, store.ClaimTTL)).Format(time.RFC3339))
cmd.PrintErrln("\nSend the link. It works once, and the key is shown only to whoever opens it.")
return nil
}
fmt.Fprintln(out, secret)
cmd.PrintErrf("\nid %s\nproject %s\n", k.ID, k.ProjectID)
if !k.ExpiresAt.IsZero() {
cmd.PrintErrf("expires %s\n", k.ExpiresAt.Format(time.RFC3339))
}
cmd.PrintErrln("\nThis is the only time the key is shown. Store it now.")
return nil
},
}
cmd.Flags().StringVar(&project, "project", "default", "project the key belongs to")
cmd.Flags().StringVar(&band, "band", "", "admission band; empty inherits")
cmd.Flags().StringVar(&profile, "profile", "", "routing profile; empty inherits")
cmd.Flags().DurationVar(&expires, "expires-in", 0, "lifetime; 0 never expires")
cmd.Flags().BoolVar(&claim, "claim", false, "print a one-time link instead of the key")
cmd.Flags().StringVar(&baseURL, "base-url", "", "public address of the gateway, for --claim")
cmd.Flags().DurationVar(&claimTTL, "claim-ttl", store.ClaimTTL, "how long the link stays usable")
return cmd
}
func orDefault(d, fallback time.Duration) time.Duration {
if d <= 0 {
return fallback
}
return d
}
func newKeyListCmd(open opener) *cobra.Command {
var project string
cmd := &cobra.Command{
Use: "list",
Short: "List a project's keys",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := open(cmd.Context())
if err != nil {
return err
}
defer s.Close()
keys, err := s.Keys(cmd.Context(), project)
if err != nil {
return err
}
if len(keys) == 0 {
fmt.Fprintf(cmd.OutOrStdout(), "no keys in project %q\n", project)
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tKEY\tBAND\tSTATE\tLAST USED")
now := time.Now()
for _, k := range keys {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
k.ID, k.Name, k.Prefix, dash(k.Band), keyState(k, now), ago(k.LastUsedAt, now))
}
return w.Flush()
},
}
cmd.Flags().StringVar(&project, "project", "default", "project to list")
return cmd
}
func newKeyRevokeCmd(open opener) *cobra.Command {
return &cobra.Command{
Use: "revoke <key-id>",
Short: "Stop a key working, without deleting it",
Long: "A soft delete. Usage rows reference the key, and an audit trail that loses\n" +
"its subject is not an audit trail.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := open(cmd.Context())
if err != nil {
return err
}
defer s.Close()
if err := s.RevokeKey(cmd.Context(), args[0], time.Now()); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
cmd.PrintErrln("A running server keeps accepting it until its cache expires (auth.cache_ttl).")
return nil
},
}
}
func newLimitsCmd(open opener) *cobra.Command {
var scope, id, period string
var rpm, tpm int
var budget int64
var models []string
cmd := &cobra.Command{
Use: "limits",
Short: "Set limits at one level of the tenancy tree",
Long: "Limits compose downward by taking the most restrictive value, so a key\n" +
"cannot raise what its organization set. Model allowlists intersect: a key\n" +
"cannot add a model its team withheld.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := open(cmd.Context())
if err != nil {
return err
}
defer s.Close()
l := store.Limits{Scope: store.Scope(scope), ScopeID: id}
if cmd.Flags().Changed("rpm") {
l.RPM = &rpm
}
if cmd.Flags().Changed("tpm") {
l.TPM = &tpm
}
if cmd.Flags().Changed("budget") {
l.Budget = &budget
l.BudgetPeriod = store.Period(period)
}
if cmd.Flags().Changed("models") {
// An explicitly empty list is an allowlist that permits
// nothing, which is different from setting no list at all.
if models == nil {
models = []string{}
}
l.AllowedModels = models
}
if err := s.PutLimits(cmd.Context(), l); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "limits set on %s %s\n", scope, id)
return nil
},
}
cmd.Flags().StringVar(&scope, "scope", "project", "org | team | project | key")
cmd.Flags().StringVar(&id, "id", "default", "id at that scope")
cmd.Flags().IntVar(&rpm, "rpm", 0, "requests per minute")
cmd.Flags().IntVar(&tpm, "tpm", 0, "tokens per minute")
cmd.Flags().Int64Var(&budget, "budget", 0, "budget per period, in micro-units")
cmd.Flags().StringVar(&period, "period", "month", "day | month")
cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none")
return cmd
}
func keyState(k store.Key, now time.Time) string {
switch {
case !k.RevokedAt.IsZero():
return "revoked"
case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt):
return "expired"
default:
return "active"
}
}
func ago(t, now time.Time) string {
if t.IsZero() {
return "never"
}
return now.Sub(t).Round(time.Second).String() + " ago"
}
// slug turns a human name into an id.
func slug(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
b.WriteByte('-')
}
}
if b.Len() == 0 {
return "default"
}
return b.String()
}
func randomID(prefix string) string {
var buf [10]byte
_, _ = rand.Read(buf[:])
return prefix + "-" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
}