NAME

Langertha::Knarr::Admin - Admin routes that deliver, rotate and drop a routing setup layered over Knarr's own, in memory only

VERSION

version 1.103

SYNOPSIS

use Langertha::Knarr;
use Langertha::Knarr::Admin;
use Langertha::Knarr::Stack;

my $stack = Langertha::Knarr::Stack->new( config => $config, loop => $loop );
my $knarr = Langertha::Knarr->new(
    loop  => $loop,
    admin => Langertha::Knarr::Admin->new(
        key      => $ENV{KNARR_ADMIN_KEY},
        stack    => $stack,
        remote_config => 1,
    ),
);

# From the central service:
#   PUT    /knarr/admin/config   { "models": {...}, "default": {...}, ... }
#   PATCH  /knarr/admin/keys     { "gpt-4o": "sk-...", "default": "sk-..." }
#   GET    /knarr/admin/status
#   DELETE /knarr/admin/config
# each with  Authorization: Bearer $KNARR_ADMIN_KEY

DESCRIPTION

The admin routes let a central service hand a running Knarr its routing setup -- the models, the default engine, the passthrough upstreams, the keys, the Langfuse section -- as JSON, rotate the keys, and take the setup back. What arrives is held in memory only: nothing is written to a file, a cache or a log, and a restarted Knarr has forgotten it, so the central service delivers it again. knarr start enables the routes when "admin_key" in Langertha::Knarr::Config (KNARR_ADMIN_KEY) is set; "remote_config" in Langertha::Knarr::Config lets Knarr start without any model.

Layers

What Knarr started with (the config file, or the environment's config) is the start layer and is always in effect. A delivered setup is the push layer and lies over it:

  • models are merged by name: a model of the push replaces the start model of the same name, start models without a namesake stay.

  • default, passthrough and the scalar keys (proxy_api_key, auto_discover, the timeouts, the probe settings): the pushed value wins, else the start layer's.

  • langfuse is merged key by key (the pushed public_key and secret_key over a start trace_name, say).

A PUT replaces the whole push layer, never the start layer; DELETE drops the push layer and falls back to the start layer alone. Keys of the start layer that no push can touch (listen, logging, workers, ...) stay.

Routes

Every route answers JSON with Cache-Control: no-store, and no answer ever contains a key.

GET /knarr/admin/status

200 with generation (how many changes since the start, 0 before the first), applied_at (ISO 8601, UTC) and applied_at_epoch (when the current state took effect), layers (the layers in effect: file or env for the start layer, when it holds anything, then push), configured, remote_config, models (the names under models:, both layers), default_engine (whether there is one) and passthrough (the protocols with an upstream, without their URLs). With "token_auth" in Langertha::Knarr::Config it also has token_auth: { backend, connected } (connected: the token store holds a database connection at this moment; the store connects on its first lookup). Never the DSN, user, password, relation or channel, and nothing of a token.

PUT /knarr/admin/config

Sets the push layer to the JSON object in the body: the keys of "runtime_keys" in Langertha::Knarr::Config (models, default, passthrough, auto_discover, proxy_api_key, the upstream timeouts, the capability probe settings and langfuse), structured as in the YAML config. A key it leaves out is not set by the push (the start layer's value applies). The process settings (listen, workers, logging, admin_key, remote_config, public_url, a2a, token_auth, routes, ...) stay as they were at the start; a body that names one is refused. Under token_auth the admin route cannot open a hole in the token check: the start layer's token_auth is part of every layered setup, so a push of models, default, passthrough, auto_discover or proxy_api_key fails the config validation (400, nothing changed), and a push of the other settings (langfuse, the timeouts) keeps the same authorizer and database connection. Keys arrive literally as api_key: an api_key_env in a model or the default is refused, because the start environment's secrets must not follow a pushed url. The layered setup is checked like a config file ("validate" in Langertha::Knarr::Config) and every model's engine, and the default engine, is built before anything changes; any problem answers 400 and the running setup stays exactly as it was. A pushed langfuse section with keys switches tracing on (or to the pushed Langfuse); its url is accepted only together with its own public_key and secret_key (the file's Langfuse keys never go to a pushed address); its keys never appear in an answer or the log. On success: 200 with the status above, under the next generation.

PATCH /knarr/admin/keys

Swaps API keys only: a JSON object of name to key, where a name is a model of either layer or default for the default engine (refused as ambiguous when a model is called default too). The key becomes that entry's api_key in the push layer (a start model is copied there with the new key; the start layer itself is never changed) and its api_key_env is dropped. All names must be known and every key a non-empty string, or nothing changes (400). 409 when there is no setup yet. Applied like a PUT of the patched push layer.

DELETE /knarr/admin/config

Drops the push layer: back to the start layer alone, or, when it holds no routing setup (remote_config with a start of no models), to no setup, where every other request answers 503.

Applying a setup

A change builds a new Langertha::Knarr::Stack -- a new Langertha::Knarr::Router, so the engine cache and the discovered models start empty -- and hands it to the Knarr in one step, under the next generation. A request that is already running, a stream in particular, goes on with the engine it started with and is not cut off. The capability probe ("probe_capabilities" in Langertha::Knarr::Config) runs again for the new router on the native server.

Keys

The admin key is the only key the admin routes accept, as Authorization: Bearer key, compared in constant time. The proxy key ("auth_token" in Langertha::Knarr) never opens them, and a request that presents the admin key to any other route is refused with 401, so the admin key never reaches an upstream. A pushed proxy_api_key equal to the admin key is refused. The admin key is held only as its SHA-256 digest.

Error messages name the problem without echoing what was sent: a body that is not JSON gets no excerpt, unknown names are counted, not listed, and every string value of the body (four characters or more) is replaced by [redacted] in a message from validation.

Wrong keys

A request without the admin key gets 401. After "free_failures" such requests in a row from one peer address, every further one locks that address out for 1, 2, 4 ... up to "max_lockout" seconds: until then its requests answer 429 with Retry-After, without even checking the key. A request with the key ends the count. Failures more than an hour apart do not add up.

Limits

The body of PUT and PATCH may be at most "max_body" bytes, else 413; under PSGI the body is not even read then, nor before the key was checked. The routes are not published anywhere: not in the provider manifest, not as a protocol route.

Each process holds its own setup, so the admin routes need a single process: Langertha::Knarr refuses "workers" in Langertha::Knarr above 1 with an admin, knarr start sets it to 1 with a warning, and under a PSGI server that sets psgi.multiprocess the routes answer 501.

key

Required, at construction only. The admin key, at least "admin_key_min_length" in Langertha::Knarr::Config characters. Only its digest is kept.

stack

Required. The Langertha::Knarr::Stack Knarr starts with; afterwards the one currently installed.

remote_config

Boolean, default 0. A start layer without any routing setup (no models, no default engine, no passthrough) is allowed: "configured" is false until the first push, and again after a DELETE.

configured

False while there is no setup to serve from ("remote_config" with an empty start layer, before the first push, or after a DELETE). Langertha::Knarr answers every route but the admin routes with 503 then.

generation

Starts at 0, one more for every change.

max_body

Largest admin request body in bytes. Default 1048576 (1 MiB).

free_failures

Wrong-key requests in a row from one address before the lockouts start. Default 2.

max_lockout

Longest lockout in seconds. Default 300.

routes

my @routes = Langertha::Knarr::Admin->routes;

The admin routes as { method, path, action } hashes.

is_key

$admin->is_key($candidate);

True when $candidate is the admin key, compared in constant time.

status

The status as a HashRef (see GET /knarr/admin/status).

handle

my ( $status, $content_type, $body, $headers ) = $admin->handle(
    knarr          => $knarr,
    action         => 'admin_put_config',
    request        => $req,          # ->header($name)
    content_length => $length,
    body           => sub { $bytes },
    peer           => $address,
    multiprocess   => 0,
);

Answers one admin request; $headers are further [ name, value ] pairs. Used by Langertha::Knarr and Langertha::Knarr::PSGI.

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/langertha-knarr/issues.

IRC

Join #langertha on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.