Files
llmgw/internal/cli/admintoken.go
Ilker ManapandClaude Opus 5.5 c0421bc7ee Guard the admin API and pprof with a token only root and the server can read
The admin listener answered any local process: models added, nodes
upgraded, engine keys listed, the heap dumped. /api and /debug now demand
the token the server writes 0600 to its state directory on first start;
ctl, the console and node add send it (sudo, LLMGW_ADMIN_TOKEN or
--token-file). pprof is off unless debug_pprof. admin_listen off loopback
is refused unless admin_remote. Dashboard pages, /metrics and /health are
unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:02:44 +02:00

101 lines
3.1 KiB
Go

package cli
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"net"
"os"
"path/filepath"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
)
// The admin token: what the admin API's /api and /debug paths demand, kept
// in a file only root and the server can read.
// defaultAdminTokenFile is where the server writes the token under its
// systemd unit — its state directory, the one place ProtectSystem=strict
// leaves it to write — and where clients look for it when told nothing.
const defaultAdminTokenFile = "/var/lib/llmgw/admin-token"
// adminTokenPath is the token's file for a configuration: as it says; under
// systemd, in the unit's state directory; else secrets/admin-token beside
// the configuration, for a server run by hand.
func adminTokenPath(cfgPath string, cfg config.Server) string {
if cfg.AdminTokenFile != "" {
return cfg.AdminTokenFile
}
if dir := os.Getenv("STATE_DIRECTORY"); dir != "" {
return filepath.Join(strings.Split(dir, ":")[0], "admin-token")
}
return filepath.Join(filepath.Dir(cfgPath), "secrets", "admin-token")
}
// ensureAdminToken reads the token, making it when there is none.
func ensureAdminToken(path string) (string, error) {
if b, err := os.ReadFile(path); err == nil { //nolint:gosec // the configured token file
if t := strings.TrimSpace(string(b)); len(t) >= 32 {
return t, nil
}
} else if !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("reading the admin token %s: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", fmt.Errorf("making %s: %w", filepath.Dir(path), err)
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
t := hex.EncodeToString(raw)
if err := os.WriteFile(path, []byte(t+"\n"), 0o600); err != nil {
return "", fmt.Errorf("writing the admin token %s: %w", path, err)
}
return t, nil
}
// readAdminToken is the token a client sends: LLMGW_ADMIN_TOKEN, else the
// first of these files it can read. Empty when none: the request then goes
// without, and the API says what is missing.
func readAdminToken(paths ...string) string {
if t := strings.TrimSpace(os.Getenv("LLMGW_ADMIN_TOKEN")); t != "" {
return t
}
for _, p := range append(paths, defaultAdminTokenFile) {
if p == "" {
continue
}
if b, err := os.ReadFile(p); err == nil { //nolint:gosec // the admin token file
if t := strings.TrimSpace(string(b)); t != "" {
return t
}
}
}
return ""
}
// checkAdminListen refuses an admin listener off loopback unless the
// configuration says it is meant.
func checkAdminListen(cfg config.Server) error {
host, _, err := net.SplitHostPort(cfg.AdminListen)
if err != nil {
return fmt.Errorf("admin_listen %q: %w", cfg.AdminListen, err)
}
if host == "localhost" {
return nil
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return nil
}
if cfg.AdminRemote {
return nil
}
return fmt.Errorf("admin_listen %s is not loopback: the admin API adds models and upgrades nodes, and "+
"its token would cross the network in clear; keep it on 127.0.0.1 and reach it by ssh, or set "+
"admin_remote: true", cfg.AdminListen)
}