Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.
Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:
* admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
* TTL leader election via NATS KV, fence tokens, JetStream dedup
* rebalancer (plan/apply split), reconciler, requeue sweeper
* ristretto caches with NATS-backed cross-node invalidation
(placements live-nodes + token denylist)
* maintenance watchdog for stuck cluster-pause flag
* Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
* rate limiting: session (10/min) + anonymous global (120/min)
* integration tests: rebalance, refcount multi-org, RLS belt
* goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea
Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
259 lines
8.9 KiB
Go
259 lines
8.9 KiB
Go
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"anchorage/internal/pkg/config"
|
|
"anchorage/internal/pkg/ids"
|
|
"anchorage/internal/pkg/token"
|
|
)
|
|
|
|
// DefaultIPFSClientTTL is the ceiling for IPFS-client tokens: 1 year
|
|
// plus a 30-day rotation grace window. That matches the config-level
|
|
// auth.apiToken.maxTTL default so `mint-token` can pick this TTL
|
|
// without tripping the signer's max-TTL guard (the signer doesn't
|
|
// currently enforce a ceiling, but this keeps the numbers honest).
|
|
const DefaultIPFSClientTTL = 9480 * time.Hour
|
|
|
|
// newMintTokenCmd mints a standalone JWT off the configured signing
|
|
// key, printing the signed string to stdout. It does NOT talk to a
|
|
// running anchorage — it reads the key off disk directly, so it works
|
|
// before any OIDC user exists.
|
|
//
|
|
// Primary use case: handing an IPFS client a bearer token.
|
|
//
|
|
// anchorage admin mint-token --role sysadmin > /etc/anchorage/client.jwt
|
|
// ipfs pin remote service add anchor https://anchor.example.com/v1 $(cat /etc/anchorage/client.jwt)
|
|
// ipfs pin remote add --service=anchor bafybeig...
|
|
//
|
|
// A minted token bypasses the active-token / denylist caches because
|
|
// it was never registered through POST /v1/tokens — this is deliberate.
|
|
// Operators who need revocation should write the jti to the denylist
|
|
// via the /v1/tokens API or rotate the signing key.
|
|
func newMintTokenCmd(flags *globalFlags) *cobra.Command {
|
|
var (
|
|
role string
|
|
orgArg string
|
|
userArg string
|
|
ttl time.Duration
|
|
scopes []string
|
|
label string
|
|
issuer string
|
|
audience string
|
|
keyPath string
|
|
kidFlag string
|
|
verbose bool
|
|
)
|
|
|
|
c := &cobra.Command{
|
|
Use: "mint-token",
|
|
Short: "Mint a standalone JWT for CLI / IPFS-client usage",
|
|
Long: `Mints a signed JWT locally from the configured signing key and
|
|
prints it to stdout. Suitable for break-glass admin access, for
|
|
providing a long-lived bearer token to 'ipfs pin remote', or for
|
|
scripts / CI pipelines that need to call anchorage without going
|
|
through the interactive Authentik login.
|
|
|
|
The default role is 'sysadmin' — which enables /v1/admin/* endpoints
|
|
(drain, uncordon, maintenance). Downgrade to 'orgadmin' or 'member'
|
|
when handing a token to a specific org.
|
|
|
|
The default TTL is ` + DefaultIPFSClientTTL.String() + ` (1 year + 30-day rotation
|
|
grace) to match the apiToken.maxTTL ceiling. Shorten it with --ttl
|
|
for short-lived tokens:
|
|
|
|
anchorage admin mint-token --role member --ttl 24h --org org_...
|
|
|
|
Minted tokens DO NOT appear in 'list my tokens' (they aren't
|
|
registered via POST /v1/tokens) and cannot be revoked individually
|
|
except by denylisting the jti or rotating the signing key.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
cfg, err := config.Load(config.LoadOptions{Path: flags.configPath, AllowMissing: true})
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
|
|
// Defaults: reach into the config's primary signingKey entry
|
|
// so operators who run mint-token alongside their configured
|
|
// server get correct key path + kid automatically.
|
|
primary, havePrimary := primarySigningKey(cfg)
|
|
if keyPath == "" && havePrimary {
|
|
keyPath = primary.Path
|
|
}
|
|
if kidFlag == "" && havePrimary {
|
|
kidFlag = primary.ID
|
|
}
|
|
if issuer == "" {
|
|
issuer = cfg.Auth.Authentik.Issuer
|
|
}
|
|
if audience == "" {
|
|
audience = cfg.Auth.Authentik.Audience
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = DefaultIPFSClientTTL
|
|
}
|
|
if !validRole(role) {
|
|
return fmt.Errorf("bad --role %q: must be sysadmin, orgadmin, or member", role)
|
|
}
|
|
|
|
// Pick between a real key file and the built-in dev key.
|
|
var key []byte
|
|
if keyPath != "" {
|
|
k, err := loadSingleKey(keyPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key = k
|
|
} else {
|
|
key = []byte(token.DevOnlySigningKey)
|
|
if kidFlag == "" {
|
|
kidFlag = token.DevKeyID
|
|
}
|
|
}
|
|
if kidFlag == "" {
|
|
return fmt.Errorf("--kid is required (or configure auth.apiToken.signingKeys in anchorage.yaml)")
|
|
}
|
|
|
|
// nil TokenStore: standalone mint, no denylist check on verify
|
|
// and no api_tokens row written. Standalone by design.
|
|
signer, err := token.NewSigner([]token.SigningKey{
|
|
{ID: kidFlag, Key: key, Primary: true},
|
|
}, issuer, audience, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("signer: %w", err)
|
|
}
|
|
|
|
orgID, err := resolveOrg(orgArg, role)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userID, err := resolveUser(userArg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
jwt, claims, err := signer.Mint(cmd.Context(), orgID, userID, role, scopes, ttl)
|
|
if err != nil {
|
|
return fmt.Errorf("mint: %w", err)
|
|
}
|
|
|
|
// A token with empty issuer / audience will fail the running
|
|
// anchorage's Parse() check (enforced via jwt.WithIssuer /
|
|
// jwt.WithAudience). Warn loudly so an operator doesn't
|
|
// silently mint a dud.
|
|
if issuer == "" || audience == "" {
|
|
fmt.Fprintln(cmd.ErrOrStderr(),
|
|
"warning: issuer or audience is empty — this token will NOT verify against a running anchorage "+
|
|
"that has auth.authentik.issuer / audience set in its config. Re-run with --issuer and "+
|
|
"--audience matching the server's config, or populate them via anchorage.yaml.")
|
|
}
|
|
|
|
// Primary output — just the token, so shell piping works:
|
|
// TOKEN=$(anchorage admin mint-token)
|
|
fmt.Fprintln(cmd.OutOrStdout(), jwt)
|
|
|
|
if verbose {
|
|
// Metadata to stderr so it doesn't contaminate piped output.
|
|
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
"# jti %s\n"+
|
|
"# role %s\n"+
|
|
"# org %s\n"+
|
|
"# user %s\n"+
|
|
"# issuer %s\n"+
|
|
"# aud %s\n"+
|
|
"# expires %s\n"+
|
|
"# label %s\n",
|
|
claims.ID, claims.Role, claims.Org, claims.User,
|
|
claims.Issuer, strings.Join([]string(claims.Audience), ","),
|
|
claims.ExpiresAt.Time.Format(time.RFC3339), label)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
c.Flags().StringVar(&role, "role", "sysadmin", "role: sysadmin | orgadmin | member")
|
|
c.Flags().StringVar(&orgArg, "org", "", "org TypeID (auto-generated if empty; not required for sysadmin)")
|
|
c.Flags().StringVar(&userArg, "user", "", "user TypeID (auto-generated if empty)")
|
|
c.Flags().DurationVar(&ttl, "ttl", DefaultIPFSClientTTL, "token lifetime (max config.auth.apiToken.maxTTL)")
|
|
c.Flags().StringSliceVar(&scopes, "scope", nil, "scope (repeatable): pin:read, pin:write, ...")
|
|
c.Flags().StringVar(&label, "label", "cli-mint", "free-form label (emitted in --verbose output)")
|
|
c.Flags().StringVar(&issuer, "issuer", "", "override JWT issuer (default: config auth.authentik.issuer)")
|
|
c.Flags().StringVar(&audience, "audience", "", "override JWT audience (default: config auth.authentik.audience)")
|
|
c.Flags().StringVar(&kidFlag, "kid", "", "key id to stamp into the JWT `kid` header (default: the primary entry in auth.apiToken.signingKeys; "+token.DevKeyID+" when no keys are configured)")
|
|
c.Flags().StringVar(&keyPath, "signing-key", "", "override signing key path (default: the primary entry in auth.apiToken.signingKeys; built-in dev key when none configured)")
|
|
c.Flags().BoolVarP(&verbose, "verbose", "v", false, "print jti + expiry + claims to stderr")
|
|
|
|
return c
|
|
}
|
|
|
|
// validRole mirrors the roles that actually mean something to the
|
|
// authz middleware. Keeping this gated at mint time avoids the
|
|
// confusing runtime path where a token with role="admin" (typo) is
|
|
// silently demoted to unauthenticated.
|
|
func validRole(r string) bool {
|
|
switch r {
|
|
case "sysadmin", "orgadmin", "member":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// primarySigningKey picks the entry flagged primary from the config.
|
|
// Returns (zero, false) when no keys are configured — mint-token falls
|
|
// back to the built-in dev key in that case.
|
|
func primarySigningKey(cfg *config.Config) (config.SigningKeyConfig, bool) {
|
|
for _, k := range cfg.Auth.APIToken.SigningKeys {
|
|
if k.Primary {
|
|
return k, true
|
|
}
|
|
}
|
|
return config.SigningKeyConfig{}, false
|
|
}
|
|
|
|
// loadSingleKey is the mint-token-only wrapper over token.LoadSigningKeys
|
|
// that expects exactly one file. Kept local so mint-token doesn't have
|
|
// to construct a full rotation spec for the single-key case.
|
|
func loadSingleKey(path string) ([]byte, error) {
|
|
keys, err := token.LoadSigningKeys([]token.KeyFileSpec{
|
|
{ID: "mint-one-shot", Path: path, Primary: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return keys[0].Key, nil
|
|
}
|
|
|
|
func resolveOrg(s, role string) (ids.OrgID, error) {
|
|
if s == "" {
|
|
if role == "sysadmin" {
|
|
// Sysadmin tokens don't need a real org; admin endpoints
|
|
// use the role, not the org. Mint a fresh one so the JWT
|
|
// is well-formed.
|
|
id, err := ids.NewOrg()
|
|
if err != nil {
|
|
return ids.OrgID{}, fmt.Errorf("generate org id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
return ids.OrgID{}, errors.New("--org is required for non-sysadmin roles")
|
|
}
|
|
return ids.ParseOrg(s)
|
|
}
|
|
|
|
func resolveUser(s string) (ids.UserID, error) {
|
|
if s == "" {
|
|
id, err := ids.NewUser()
|
|
if err != nil {
|
|
return ids.UserID{}, fmt.Errorf("generate user id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
return ids.ParseUser(s)
|
|
}
|