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>
101 lines
3.1 KiB
Go
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)
|
|
}
|