Files
hammy-backend/cmd/hammyd/keygen.go
T
2026-09-06 18:28:52 +02:00

160 lines
4.0 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.dcrubro.com/dcrubro/hammy-backend/internal/apikey"
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
)
// runKeygen issues an API key from the command line. This is how you get the
// first key, before there is any self-service registration, and how you issue
// yourself something to test against.
//
// hammyd keygen -email [email protected] -scopes reference,callsign
func runKeygen(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
email := fs.String("email", "", "owner email (required); created if new")
callsign := fs.String("callsign", "", "owner callsign (optional, uppercase)")
label := fs.String("label", "", "what this key is for, shown in listings")
scopes := fs.String("scopes", "reference", "comma-separated scopes")
tier := fs.String("tier", "default", "quota tier: default, verified, trusted, internal")
env := fs.String("env", "live", "live or test")
expires := fs.Duration("expires", 0, "optional lifetime, e.g. 720h. Zero means no expiry")
if err := fs.Parse(args); err != nil {
return err
}
if *email == "" {
fs.Usage()
return errors.New("keygen: -email is required")
}
// The core.email domain requires lowercase and core.callsign requires
// uppercase. Normalise here so the CLI is forgiving and the database stays
// canonical.
normEmail := strings.ToLower(strings.TrimSpace(*email))
normCall := strings.ToUpper(strings.TrimSpace(*callsign))
dsn := os.Getenv("HAMMY_DSN")
if dsn == "" {
return errors.New("keygen: HAMMY_DSN is not set")
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return fmt.Errorf("keygen: connect: %w", err)
}
defer pool.Close()
q := db.New(pool)
owner, err := q.OwnerByEmail(ctx, normEmail)
if errors.Is(err, pgx.ErrNoRows) {
created, cerr := q.CreateOwner(ctx, db.CreateOwnerParams{
Email: normEmail,
Callsign: nullableCallsign(normCall),
})
if cerr != nil {
return fmt.Errorf("keygen: creating owner: %w", cerr)
}
owner.ID = created.ID
fmt.Printf("created owner %d for %s\n", owner.ID, normEmail)
} else if err != nil {
return fmt.Errorf("keygen: looking up owner: %w", err)
}
gen, err := apikey.Generate(apikey.Environment(*env))
if err != nil {
return fmt.Errorf("keygen: %w", err)
}
var expiresAt *time.Time
if *expires > 0 {
t := time.Now().Add(*expires)
expiresAt = &t
}
scopeList := splitScopes(*scopes)
key, err := q.CreateKey(ctx, db.CreateKeyParams{
OwnerID: owner.ID,
KeyHash: gen.Hash,
KeyPrefix: gen.Display,
Label: nullableString(*label),
Scopes: scopeList,
QuotaTier: *tier,
ExpiresAt: expiresAt,
})
if err != nil {
// The keys_scopes_known CHECK rejects unknown scopes, which is the
// most likely failure here and worth naming.
return fmt.Errorf("keygen: creating key (check scopes against migrations/003_api.sql): %w", err)
}
fmt.Println()
fmt.Println("Key issued. This is the only time it will be shown.")
fmt.Println()
fmt.Printf(" %s\n", gen.Plaintext)
fmt.Println()
fmt.Printf(" id %d\n", key.ID)
fmt.Printf(" owner %d (%s)\n", owner.ID, normEmail)
fmt.Printf(" scopes %s\n", strings.Join(scopeList, ", "))
fmt.Printf(" tier %s\n", key.QuotaTier)
if expiresAt != nil {
fmt.Printf(" expires %s\n", expiresAt.UTC().Format(time.RFC3339))
} else {
fmt.Printf(" expires never\n")
}
fmt.Println()
fmt.Println("Test it with:")
fmt.Printf(" curl -H 'Authorization: Bearer %s' http://localhost:8080/v1/whoami\n", gen.Plaintext)
fmt.Println()
return nil
}
func splitScopes(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func nullableString(s string) *string {
if s == "" {
return nil
}
return &s
}
func nullableCallsign(s string) *string {
if s == "" {
return nil
}
return &s
}